From c6063dd8462ffd9d451d5d352e52ac301d125b22 Mon Sep 17 00:00:00 2001 From: Sean Sullivan Date: Thu, 1 Aug 2019 11:01:40 -0700 Subject: [PATCH] Move pkg/kubectl/cmd/{command} to staging Kubernetes-commit: 0e0ea523392f1121f61f99ac30a9bc2043eaed90 --- pkg/cmd/alpha.go | 49 + pkg/cmd/annotate/annotate.go | 382 ++++ pkg/cmd/annotate/annotate_test.go | 643 ++++++ pkg/cmd/apiresources/apiresources.go | 248 +++ pkg/cmd/apiresources/apiversions.go | 97 + pkg/cmd/apply/apply.go | 997 +++++++++ pkg/cmd/apply/apply_edit_last_applied.go | 89 + pkg/cmd/apply/apply_set_last_applied.go | 219 ++ pkg/cmd/apply/apply_test.go | 1414 +++++++++++++ pkg/cmd/apply/apply_view_last_applied.go | 172 ++ pkg/cmd/attach/attach.go | 344 +++ pkg/cmd/attach/attach_test.go | 422 ++++ pkg/cmd/autoscale/autoscale.go | 284 +++ pkg/cmd/certificates/certificates.go | 258 +++ pkg/cmd/clusterinfo/clusterinfo.go | 166 ++ pkg/cmd/clusterinfo/clusterinfo_dump.go | 295 +++ pkg/cmd/clusterinfo/clusterinfo_dump_test.go | 70 + pkg/cmd/completion/completion.go | 314 +++ pkg/cmd/config/config.go | 92 + pkg/cmd/config/config_test.go | 949 +++++++++ pkg/cmd/config/create_authinfo.go | 437 ++++ pkg/cmd/config/create_authinfo_test.go | 493 +++++ pkg/cmd/config/create_cluster.go | 180 ++ pkg/cmd/config/create_cluster_test.go | 119 ++ pkg/cmd/config/create_context.go | 153 ++ pkg/cmd/config/create_context_test.go | 147 ++ pkg/cmd/config/current_context.go | 76 + pkg/cmd/config/current_context_test.go | 93 + pkg/cmd/config/delete_cluster.go | 84 + pkg/cmd/config/delete_cluster_test.go | 97 + pkg/cmd/config/delete_context.go | 88 + pkg/cmd/config/delete_context_test.go | 98 + pkg/cmd/config/get_clusters.go | 64 + pkg/cmd/config/get_clusters_test.go | 84 + pkg/cmd/config/get_contexts.go | 180 ++ pkg/cmd/config/get_contexts_test.go | 178 ++ pkg/cmd/config/navigation_step_parser.go | 154 ++ pkg/cmd/config/navigation_step_parser_test.go | 108 + pkg/cmd/config/rename_context.go | 132 ++ pkg/cmd/config/rename_context_test.go | 156 ++ pkg/cmd/config/set.go | 262 +++ pkg/cmd/config/set_test.go | 88 + pkg/cmd/config/unset.go | 116 ++ pkg/cmd/config/unset_test.go | 150 ++ pkg/cmd/config/use_context.go | 103 + pkg/cmd/config/use_context_test.go | 104 + pkg/cmd/config/view.go | 185 ++ pkg/cmd/config/view_test.go | 201 ++ pkg/cmd/cp/cp.go | 540 +++++ pkg/cmd/cp/cp_test.go | 975 +++++++++ pkg/cmd/create/create.go | 440 ++++ pkg/cmd/create/create_clusterrole.go | 210 ++ pkg/cmd/create/create_clusterrole_test.go | 513 +++++ pkg/cmd/create/create_clusterrolebinding.go | 102 + .../create/create_clusterrolebinding_test.go | 147 ++ pkg/cmd/create/create_configmap.go | 123 ++ pkg/cmd/create/create_configmap_test.go | 61 + pkg/cmd/create/create_cronjob.go | 205 ++ pkg/cmd/create/create_cronjob_test.go | 121 ++ pkg/cmd/create/create_deployment.go | 155 ++ pkg/cmd/create/create_deployment_test.go | 155 ++ pkg/cmd/create/create_job.go | 248 +++ pkg/cmd/create/create_job_test.go | 196 ++ pkg/cmd/create/create_namespace.go | 93 + pkg/cmd/create/create_namespace_test.go | 61 + pkg/cmd/create/create_pdb.go | 112 + pkg/cmd/create/create_pdb_test.go | 85 + pkg/cmd/create/create_priorityclass.go | 110 + pkg/cmd/create/create_priorityclass_test.go | 87 + pkg/cmd/create/create_quota.go | 101 + pkg/cmd/create/create_quota_test.go | 68 + pkg/cmd/create/create_role.go | 402 ++++ pkg/cmd/create/create_role_test.go | 673 ++++++ pkg/cmd/create/create_rolebinding.go | 103 + pkg/cmd/create/create_rolebinding_test.go | 144 ++ pkg/cmd/create/create_secret.go | 322 +++ pkg/cmd/create/create_secret_test.go | 101 + pkg/cmd/create/create_service.go | 342 +++ pkg/cmd/create/create_service_test.go | 128 ++ pkg/cmd/create/create_serviceaccount.go | 92 + pkg/cmd/create/create_serviceaccount_test.go | 61 + pkg/cmd/create/create_test.go | 151 ++ pkg/cmd/delete/delete.go | 392 ++++ pkg/cmd/delete/delete_flags.go | 214 ++ pkg/cmd/delete/delete_test.go | 713 +++++++ pkg/cmd/describe/describe.go | 246 +++ pkg/cmd/describe/describe_test.go | 262 +++ pkg/cmd/diff/diff.go | 509 +++++ pkg/cmd/diff/diff_test.go | 196 ++ pkg/cmd/drain/drain.go | 535 +++++ pkg/cmd/drain/drain_test.go | 1050 ++++++++++ pkg/cmd/edit/edit.go | 105 + pkg/cmd/edit/edit_test.go | 292 +++ pkg/cmd/edit/testdata/README | 22 + pkg/cmd/edit/testdata/record.go | 169 ++ pkg/cmd/edit/testdata/record_editor.sh | 26 + pkg/cmd/edit/testdata/record_testcase.sh | 70 + pkg/cmd/edit/testdata/test_editor.sh | 32 + .../0.request | 0 .../0.response | 21 + .../1.request | 0 .../1.response | 35 + .../2.edited | 38 + .../2.original | 36 + .../3.edited | 41 + .../3.original | 40 + .../4.request | 7 + .../4.response | 21 + .../5.request | 7 + .../5.response | 35 + .../test.yaml | 43 + .../0.request | 0 .../0.response | 21 + .../1.request | 0 .../1.response | 35 + .../2.edited | 39 + .../2.original | 36 + .../3.request | 7 + .../3.response | 21 + .../4.request | 7 + .../4.response | 35 + .../test.yaml | 40 + .../0.request | 0 .../0.response | 35 + .../1.edited | 21 + .../1.original | 21 + .../2.edited | 24 + .../2.original | 23 + .../3.request | 7 + .../3.response | 35 + .../test.yaml | 28 + .../0.request | 0 .../0.response | 38 + .../testcase-apply-edit-last-applied/1.edited | 26 + .../1.original | 26 + .../2.request | 7 + .../2.response | 38 + .../test.yaml | 27 + .../testcase-create-list-error/0.edited | 28 + .../testcase-create-list-error/0.original | 28 + .../testcase-create-list-error/1.request | 33 + .../testcase-create-list-error/1.response | 34 + .../testcase-create-list-error/2.edited | 28 + .../testcase-create-list-error/2.original | 28 + .../testcase-create-list-error/3.request | 33 + .../testcase-create-list-error/3.response | 25 + .../testcase-create-list-error/svc.yaml | 54 + .../testcase-create-list-error/test.yaml | 30 + .../testdata/testcase-create-list/0.edited | 22 + .../testdata/testcase-create-list/0.original | 21 + .../testdata/testcase-create-list/1.request | 27 + .../testdata/testcase-create-list/1.response | 35 + .../testdata/testcase-create-list/2.edited | 22 + .../testdata/testcase-create-list/2.original | 21 + .../testdata/testcase-create-list/3.request | 27 + .../testdata/testcase-create-list/3.response | 35 + .../testdata/testcase-create-list/svc.yaml | 39 + .../testdata/testcase-create-list/test.yaml | 29 + .../testcase-edit-error-reedit/0.request | 0 .../testcase-edit-error-reedit/0.response | 35 + .../testcase-edit-error-reedit/1.edited | 29 + .../testcase-edit-error-reedit/1.original | 29 + .../testcase-edit-error-reedit/2.request | 5 + .../testcase-edit-error-reedit/2.response | 25 + .../testcase-edit-error-reedit/3.edited | 33 + .../testcase-edit-error-reedit/3.original | 33 + .../testcase-edit-error-reedit/4.request | 21 + .../testcase-edit-error-reedit/4.response | 35 + .../testcase-edit-error-reedit/test.yaml | 38 + .../testcase-edit-from-empty/0.request | 0 .../testcase-edit-from-empty/0.response | 9 + .../testcase-edit-from-empty/test.yaml | 15 + .../testcase-edit-output-patch/0.request | 0 .../testcase-edit-output-patch/0.response | 37 + .../testcase-edit-output-patch/1.edited | 32 + .../testcase-edit-output-patch/1.original | 31 + .../testcase-edit-output-patch/2.request | 10 + .../testcase-edit-output-patch/2.response | 38 + .../testcase-edit-output-patch/test.yaml | 32 + .../testcase-immutable-name/0.request | 0 .../testcase-immutable-name/0.response | 24 + .../testdata/testcase-immutable-name/1.edited | 16 + .../testcase-immutable-name/1.original | 16 + .../testcase-immutable-name/test.yaml | 18 + .../testdata/testcase-list-errors/0.request | 0 .../testdata/testcase-list-errors/0.response | 24 + .../testdata/testcase-list-errors/1.request | 0 .../testdata/testcase-list-errors/1.response | 39 + .../testdata/testcase-list-errors/10.request | 5 + .../testdata/testcase-list-errors/10.response | 16 + .../testdata/testcase-list-errors/2.edited | 42 + .../testdata/testcase-list-errors/2.original | 42 + .../testdata/testcase-list-errors/3.request | 16 + .../testdata/testcase-list-errors/3.response | 25 + .../testdata/testcase-list-errors/4.request | 5 + .../testdata/testcase-list-errors/4.response | 16 + .../testdata/testcase-list-errors/5.edited | 47 + .../testdata/testcase-list-errors/5.original | 46 + .../testdata/testcase-list-errors/6.request | 26 + .../testdata/testcase-list-errors/6.response | 20 + .../testdata/testcase-list-errors/7.request | 5 + .../testdata/testcase-list-errors/7.response | 16 + .../testdata/testcase-list-errors/8.edited | 46 + .../testdata/testcase-list-errors/8.original | 46 + .../testdata/testcase-list-errors/9.request | 26 + .../testdata/testcase-list-errors/9.response | 32 + .../testdata/testcase-list-errors/test.yaml | 73 + .../testdata/testcase-list-record/0.request | 0 .../testdata/testcase-list-record/0.response | 19 + .../testdata/testcase-list-record/1.request | 0 .../testdata/testcase-list-record/1.response | 33 + .../testdata/testcase-list-record/2.edited | 51 + .../testdata/testcase-list-record/2.original | 49 + .../testdata/testcase-list-record/3.request | 5 + .../testdata/testcase-list-record/3.response | 20 + .../testdata/testcase-list-record/4.request | 26 + .../testdata/testcase-list-record/4.response | 34 + .../testdata/testcase-list-record/test.yaml | 40 + pkg/cmd/edit/testdata/testcase-list/0.request | 0 .../edit/testdata/testcase-list/0.response | 18 + pkg/cmd/edit/testdata/testcase-list/1.request | 0 .../edit/testdata/testcase-list/1.response | 32 + pkg/cmd/edit/testdata/testcase-list/2.edited | 47 + .../edit/testdata/testcase-list/2.original | 45 + pkg/cmd/edit/testdata/testcase-list/3.request | 5 + .../edit/testdata/testcase-list/3.response | 19 + pkg/cmd/edit/testdata/testcase-list/4.request | 26 + .../edit/testdata/testcase-list/4.response | 33 + pkg/cmd/edit/testdata/testcase-list/test.yaml | 40 + .../testcase-missing-service/0.request | 0 .../testcase-missing-service/0.response | 13 + .../testcase-missing-service/test.yaml | 15 + .../edit/testdata/testcase-no-op/0.request | 0 .../edit/testdata/testcase-no-op/0.response | 12 + pkg/cmd/edit/testdata/testcase-no-op/1.edited | 13 + .../edit/testdata/testcase-no-op/1.original | 13 + .../edit/testdata/testcase-no-op/test.yaml | 18 + .../testcase-not-update-annotation/0.request | 0 .../testcase-not-update-annotation/0.response | 37 + .../testcase-not-update-annotation/1.edited | 32 + .../testcase-not-update-annotation/1.original | 31 + .../testcase-not-update-annotation/2.request | 7 + .../testcase-not-update-annotation/2.response | 38 + .../testcase-not-update-annotation/test.yaml | 30 + .../testdata/testcase-repeat-error/0.request | 0 .../testdata/testcase-repeat-error/0.response | 32 + .../testdata/testcase-repeat-error/1.edited | 27 + .../testdata/testcase-repeat-error/1.original | 27 + .../testdata/testcase-repeat-error/2.request | 5 + .../testdata/testcase-repeat-error/2.response | 25 + .../testdata/testcase-repeat-error/3.edited | 31 + .../testdata/testcase-repeat-error/3.original | 31 + .../testdata/testcase-repeat-error/test.yaml | 30 + .../testcase-schemaless-list/0.request | 0 .../testcase-schemaless-list/0.response | 32 + .../testcase-schemaless-list/1.request | 0 .../testcase-schemaless-list/1.response | 16 + .../testcase-schemaless-list/2.request | 0 .../testcase-schemaless-list/2.response | 21 + .../testcase-schemaless-list/3.edited | 62 + .../testcase-schemaless-list/3.original | 59 + .../testcase-schemaless-list/4.request | 7 + .../testcase-schemaless-list/4.response | 33 + .../testcase-schemaless-list/5.request | 3 + .../testcase-schemaless-list/5.response | 17 + .../testcase-schemaless-list/6.request | 6 + .../testcase-schemaless-list/6.response | 22 + .../testcase-schemaless-list/test.yaml | 55 + .../testcase-single-service/0.request | 0 .../testcase-single-service/0.response | 34 + .../testdata/testcase-single-service/1.edited | 29 + .../testcase-single-service/1.original | 28 + .../testcase-single-service/2.request | 26 + .../testcase-single-service/2.response | 35 + .../testcase-single-service/test.yaml | 29 + .../testdata/testcase-syntax-error/0.request | 0 .../testdata/testcase-syntax-error/0.response | 32 + .../testdata/testcase-syntax-error/1.edited | 27 + .../testdata/testcase-syntax-error/1.original | 27 + .../testdata/testcase-syntax-error/2.edited | 30 + .../testdata/testcase-syntax-error/2.original | 29 + .../testdata/testcase-syntax-error/3.request | 7 + .../testdata/testcase-syntax-error/3.response | 33 + .../testdata/testcase-syntax-error/test.yaml | 28 + .../0.request | 0 .../0.response | 25 + .../1.edited | 23 + .../1.original | 22 + .../2.request | 11 + .../2.response | 26 + .../test.yaml | 25 + .../0.request | 0 .../0.response | 22 + .../1.edited | 22 + .../1.original | 20 + .../2.request | 10 + .../2.response | 24 + .../test.yaml | 25 + .../testcase-update-annotation/0.request | 0 .../testcase-update-annotation/0.response | 37 + .../testcase-update-annotation/1.edited | 32 + .../testcase-update-annotation/1.original | 31 + .../testcase-update-annotation/2.request | 10 + .../testcase-update-annotation/2.response | 38 + .../testcase-update-annotation/test.yaml | 30 + pkg/cmd/exec/exec.go | 367 ++++ pkg/cmd/exec/exec_test.go | 407 ++++ pkg/cmd/explain/explain.go | 158 ++ pkg/cmd/expose/expose.go | 359 ++++ pkg/cmd/expose/expose_test.go | 639 ++++++ pkg/cmd/help/help.go | 84 + pkg/cmd/kustomize/kustomize.go | 92 + pkg/cmd/kustomize/kustomize_test.go | 56 + pkg/cmd/label/label.go | 421 ++++ pkg/cmd/label/label_test.go | 498 +++++ pkg/cmd/logs/logs.go | 393 ++++ pkg/cmd/logs/logs_test.go | 545 +++++ pkg/cmd/options/options.go | 55 + pkg/cmd/patch/patch.go | 316 +++ pkg/cmd/patch/patch_test.go | 192 ++ pkg/cmd/plugin/plugin.go | 275 +++ pkg/cmd/plugin/plugin_test.go | 197 ++ pkg/cmd/plugin/testdata/kubectl-foo | 3 + pkg/cmd/plugin/testdata/kubectl-version | 4 + pkg/cmd/portforward/portforward.go | 341 +++ pkg/cmd/portforward/portforward_test.go | 781 +++++++ pkg/cmd/profiling.go | 88 + pkg/cmd/proxy/proxy.go | 165 ++ pkg/cmd/replace/replace.go | 374 ++++ pkg/cmd/replace/replace_test.go | 250 +++ pkg/cmd/rollingupdate/rolling_updater.go | 865 ++++++++ pkg/cmd/rollingupdate/rolling_updater_test.go | 1852 +++++++++++++++++ pkg/cmd/rollingupdate/rollingupdate.go | 468 +++++ pkg/cmd/rollingupdate/rollingupdate_test.go | 92 + pkg/cmd/rollout/rollout.go | 67 + pkg/cmd/rollout/rollout_history.go | 179 ++ pkg/cmd/rollout/rollout_pause.go | 194 ++ pkg/cmd/rollout/rollout_pause_test.go | 115 + pkg/cmd/rollout/rollout_restart.go | 190 ++ pkg/cmd/rollout/rollout_resume.go | 198 ++ pkg/cmd/rollout/rollout_status.go | 251 +++ pkg/cmd/rollout/rollout_undo.go | 173 ++ pkg/cmd/run/run.go | 770 +++++++ pkg/cmd/run/run_test.go | 537 +++++ pkg/cmd/scale/scale.go | 267 +++ pkg/cmd/set/env/doc.go | 18 + pkg/cmd/set/env/env_parse.go | 137 ++ pkg/cmd/set/env/env_parse_test.go | 69 + pkg/cmd/set/env/env_resolve.go | 270 +++ pkg/cmd/set/helper.go | 156 ++ pkg/cmd/set/set.go | 53 + pkg/cmd/set/set_env.go | 511 +++++ pkg/cmd/set/set_env_test.go | 668 ++++++ pkg/cmd/set/set_image.go | 317 +++ pkg/cmd/set/set_image_test.go | 776 +++++++ pkg/cmd/set/set_resources.go | 293 +++ pkg/cmd/set/set_resources_test.go | 518 +++++ pkg/cmd/set/set_selector.go | 225 ++ pkg/cmd/set/set_selector_test.go | 342 +++ pkg/cmd/set/set_serviceaccount.go | 216 ++ pkg/cmd/set/set_serviceaccount_test.go | 407 ++++ pkg/cmd/set/set_subject.go | 310 +++ pkg/cmd/set/set_subject_test.go | 426 ++++ pkg/cmd/set/set_test.go | 45 + pkg/cmd/taint/taint.go | 306 +++ pkg/cmd/taint/taint_test.go | 402 ++++ pkg/cmd/taint/utils.go | 219 ++ pkg/cmd/taint/utils_test.go | 533 +++++ pkg/cmd/testing/fake.go | 668 ++++++ pkg/cmd/testing/interfaces.go | 32 + pkg/cmd/testing/util.go | 165 ++ pkg/cmd/testing/zz_generated.deepcopy.go | 169 ++ pkg/cmd/top/top.go | 76 + pkg/cmd/top/top_node.go | 249 +++ pkg/cmd/top/top_node_test.go | 496 +++++ pkg/cmd/top/top_pod.go | 272 +++ pkg/cmd/top/top_pod_test.go | 737 +++++++ pkg/cmd/top/top_test.go | 174 ++ pkg/cmd/version/version.go | 162 ++ pkg/cmd/wait/wait.go | 460 ++++ pkg/cmd/wait/wait_test.go | 789 +++++++ 381 files changed, 53901 insertions(+) create mode 100644 pkg/cmd/alpha.go create mode 100644 pkg/cmd/annotate/annotate.go create mode 100644 pkg/cmd/annotate/annotate_test.go create mode 100644 pkg/cmd/apiresources/apiresources.go create mode 100644 pkg/cmd/apiresources/apiversions.go create mode 100644 pkg/cmd/apply/apply.go create mode 100644 pkg/cmd/apply/apply_edit_last_applied.go create mode 100644 pkg/cmd/apply/apply_set_last_applied.go create mode 100644 pkg/cmd/apply/apply_test.go create mode 100644 pkg/cmd/apply/apply_view_last_applied.go create mode 100644 pkg/cmd/attach/attach.go create mode 100644 pkg/cmd/attach/attach_test.go create mode 100644 pkg/cmd/autoscale/autoscale.go create mode 100644 pkg/cmd/certificates/certificates.go create mode 100644 pkg/cmd/clusterinfo/clusterinfo.go create mode 100644 pkg/cmd/clusterinfo/clusterinfo_dump.go create mode 100644 pkg/cmd/clusterinfo/clusterinfo_dump_test.go create mode 100644 pkg/cmd/completion/completion.go create mode 100644 pkg/cmd/config/config.go create mode 100644 pkg/cmd/config/config_test.go create mode 100644 pkg/cmd/config/create_authinfo.go create mode 100644 pkg/cmd/config/create_authinfo_test.go create mode 100644 pkg/cmd/config/create_cluster.go create mode 100644 pkg/cmd/config/create_cluster_test.go create mode 100644 pkg/cmd/config/create_context.go create mode 100644 pkg/cmd/config/create_context_test.go create mode 100644 pkg/cmd/config/current_context.go create mode 100644 pkg/cmd/config/current_context_test.go create mode 100644 pkg/cmd/config/delete_cluster.go create mode 100644 pkg/cmd/config/delete_cluster_test.go create mode 100644 pkg/cmd/config/delete_context.go create mode 100644 pkg/cmd/config/delete_context_test.go create mode 100644 pkg/cmd/config/get_clusters.go create mode 100644 pkg/cmd/config/get_clusters_test.go create mode 100644 pkg/cmd/config/get_contexts.go create mode 100644 pkg/cmd/config/get_contexts_test.go create mode 100644 pkg/cmd/config/navigation_step_parser.go create mode 100644 pkg/cmd/config/navigation_step_parser_test.go create mode 100644 pkg/cmd/config/rename_context.go create mode 100644 pkg/cmd/config/rename_context_test.go create mode 100644 pkg/cmd/config/set.go create mode 100644 pkg/cmd/config/set_test.go create mode 100644 pkg/cmd/config/unset.go create mode 100644 pkg/cmd/config/unset_test.go create mode 100644 pkg/cmd/config/use_context.go create mode 100644 pkg/cmd/config/use_context_test.go create mode 100644 pkg/cmd/config/view.go create mode 100644 pkg/cmd/config/view_test.go create mode 100644 pkg/cmd/cp/cp.go create mode 100644 pkg/cmd/cp/cp_test.go create mode 100644 pkg/cmd/create/create.go create mode 100644 pkg/cmd/create/create_clusterrole.go create mode 100644 pkg/cmd/create/create_clusterrole_test.go create mode 100644 pkg/cmd/create/create_clusterrolebinding.go create mode 100644 pkg/cmd/create/create_clusterrolebinding_test.go create mode 100644 pkg/cmd/create/create_configmap.go create mode 100644 pkg/cmd/create/create_configmap_test.go create mode 100644 pkg/cmd/create/create_cronjob.go create mode 100644 pkg/cmd/create/create_cronjob_test.go create mode 100644 pkg/cmd/create/create_deployment.go create mode 100644 pkg/cmd/create/create_deployment_test.go create mode 100644 pkg/cmd/create/create_job.go create mode 100644 pkg/cmd/create/create_job_test.go create mode 100644 pkg/cmd/create/create_namespace.go create mode 100644 pkg/cmd/create/create_namespace_test.go create mode 100644 pkg/cmd/create/create_pdb.go create mode 100644 pkg/cmd/create/create_pdb_test.go create mode 100644 pkg/cmd/create/create_priorityclass.go create mode 100644 pkg/cmd/create/create_priorityclass_test.go create mode 100644 pkg/cmd/create/create_quota.go create mode 100644 pkg/cmd/create/create_quota_test.go create mode 100644 pkg/cmd/create/create_role.go create mode 100644 pkg/cmd/create/create_role_test.go create mode 100644 pkg/cmd/create/create_rolebinding.go create mode 100644 pkg/cmd/create/create_rolebinding_test.go create mode 100644 pkg/cmd/create/create_secret.go create mode 100644 pkg/cmd/create/create_secret_test.go create mode 100644 pkg/cmd/create/create_service.go create mode 100644 pkg/cmd/create/create_service_test.go create mode 100644 pkg/cmd/create/create_serviceaccount.go create mode 100644 pkg/cmd/create/create_serviceaccount_test.go create mode 100644 pkg/cmd/create/create_test.go create mode 100644 pkg/cmd/delete/delete.go create mode 100644 pkg/cmd/delete/delete_flags.go create mode 100644 pkg/cmd/delete/delete_test.go create mode 100644 pkg/cmd/describe/describe.go create mode 100644 pkg/cmd/describe/describe_test.go create mode 100644 pkg/cmd/diff/diff.go create mode 100644 pkg/cmd/diff/diff_test.go create mode 100644 pkg/cmd/drain/drain.go create mode 100644 pkg/cmd/drain/drain_test.go create mode 100644 pkg/cmd/edit/edit.go create mode 100644 pkg/cmd/edit/edit_test.go create mode 100644 pkg/cmd/edit/testdata/README create mode 100644 pkg/cmd/edit/testdata/record.go create mode 100755 pkg/cmd/edit/testdata/record_editor.sh create mode 100755 pkg/cmd/edit/testdata/record_testcase.sh create mode 100755 pkg/cmd/edit/testdata/test_editor.sh create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/1.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/1.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/2.edited create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/2.original create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/3.edited create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/3.original create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/4.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/4.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/5.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/5.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/1.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/1.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/2.edited create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/2.original create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/3.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/3.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/4.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/4.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/2.edited create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/2.original create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/3.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/3.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-create-list-error/0.edited create mode 100644 pkg/cmd/edit/testdata/testcase-create-list-error/0.original create mode 100644 pkg/cmd/edit/testdata/testcase-create-list-error/1.request create mode 100644 pkg/cmd/edit/testdata/testcase-create-list-error/1.response create mode 100644 pkg/cmd/edit/testdata/testcase-create-list-error/2.edited create mode 100644 pkg/cmd/edit/testdata/testcase-create-list-error/2.original create mode 100644 pkg/cmd/edit/testdata/testcase-create-list-error/3.request create mode 100644 pkg/cmd/edit/testdata/testcase-create-list-error/3.response create mode 100644 pkg/cmd/edit/testdata/testcase-create-list-error/svc.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-create-list-error/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-create-list/0.edited create mode 100644 pkg/cmd/edit/testdata/testcase-create-list/0.original create mode 100644 pkg/cmd/edit/testdata/testcase-create-list/1.request create mode 100644 pkg/cmd/edit/testdata/testcase-create-list/1.response create mode 100644 pkg/cmd/edit/testdata/testcase-create-list/2.edited create mode 100644 pkg/cmd/edit/testdata/testcase-create-list/2.original create mode 100644 pkg/cmd/edit/testdata/testcase-create-list/3.request create mode 100644 pkg/cmd/edit/testdata/testcase-create-list/3.response create mode 100644 pkg/cmd/edit/testdata/testcase-create-list/svc.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-create-list/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/3.edited create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/3.original create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/4.request create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/4.response create mode 100644 pkg/cmd/edit/testdata/testcase-edit-error-reedit/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-edit-from-empty/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-edit-from-empty/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-edit-from-empty/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-edit-output-patch/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-edit-output-patch/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-edit-output-patch/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-edit-output-patch/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-edit-output-patch/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-edit-output-patch/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-edit-output-patch/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-immutable-name/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-immutable-name/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-immutable-name/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-immutable-name/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-immutable-name/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/1.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/1.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/10.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/10.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/2.edited create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/2.original create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/3.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/3.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/4.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/4.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/5.edited create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/5.original create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/6.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/6.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/7.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/7.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/8.edited create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/8.original create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/9.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/9.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-errors/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/1.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/1.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/2.edited create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/2.original create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/3.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/3.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/4.request create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/4.response create mode 100644 pkg/cmd/edit/testdata/testcase-list-record/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-list/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-list/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-list/1.request create mode 100644 pkg/cmd/edit/testdata/testcase-list/1.response create mode 100644 pkg/cmd/edit/testdata/testcase-list/2.edited create mode 100644 pkg/cmd/edit/testdata/testcase-list/2.original create mode 100644 pkg/cmd/edit/testdata/testcase-list/3.request create mode 100644 pkg/cmd/edit/testdata/testcase-list/3.response create mode 100644 pkg/cmd/edit/testdata/testcase-list/4.request create mode 100644 pkg/cmd/edit/testdata/testcase-list/4.response create mode 100644 pkg/cmd/edit/testdata/testcase-list/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-missing-service/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-missing-service/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-missing-service/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-no-op/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-no-op/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-no-op/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-no-op/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-no-op/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-not-update-annotation/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-not-update-annotation/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-not-update-annotation/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-not-update-annotation/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-not-update-annotation/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-not-update-annotation/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-not-update-annotation/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-repeat-error/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-repeat-error/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-repeat-error/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-repeat-error/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-repeat-error/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-repeat-error/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-repeat-error/3.edited create mode 100644 pkg/cmd/edit/testdata/testcase-repeat-error/3.original create mode 100644 pkg/cmd/edit/testdata/testcase-repeat-error/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/1.request create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/1.response create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/3.edited create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/3.original create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/4.request create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/4.response create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/5.request create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/5.response create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/6.request create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/6.response create mode 100644 pkg/cmd/edit/testdata/testcase-schemaless-list/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-single-service/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-single-service/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-single-service/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-single-service/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-single-service/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-single-service/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-single-service/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-syntax-error/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-syntax-error/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-syntax-error/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-syntax-error/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-syntax-error/2.edited create mode 100644 pkg/cmd/edit/testdata/testcase-syntax-error/2.original create mode 100644 pkg/cmd/edit/testdata/testcase-syntax-error/3.request create mode 100644 pkg/cmd/edit/testdata/testcase-syntax-error/3.response create mode 100644 pkg/cmd/edit/testdata/testcase-syntax-error/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/test.yaml create mode 100644 pkg/cmd/edit/testdata/testcase-update-annotation/0.request create mode 100644 pkg/cmd/edit/testdata/testcase-update-annotation/0.response create mode 100644 pkg/cmd/edit/testdata/testcase-update-annotation/1.edited create mode 100644 pkg/cmd/edit/testdata/testcase-update-annotation/1.original create mode 100644 pkg/cmd/edit/testdata/testcase-update-annotation/2.request create mode 100644 pkg/cmd/edit/testdata/testcase-update-annotation/2.response create mode 100644 pkg/cmd/edit/testdata/testcase-update-annotation/test.yaml create mode 100644 pkg/cmd/exec/exec.go create mode 100644 pkg/cmd/exec/exec_test.go create mode 100644 pkg/cmd/explain/explain.go create mode 100644 pkg/cmd/expose/expose.go create mode 100644 pkg/cmd/expose/expose_test.go create mode 100644 pkg/cmd/help/help.go create mode 100644 pkg/cmd/kustomize/kustomize.go create mode 100644 pkg/cmd/kustomize/kustomize_test.go create mode 100644 pkg/cmd/label/label.go create mode 100644 pkg/cmd/label/label_test.go create mode 100644 pkg/cmd/logs/logs.go create mode 100644 pkg/cmd/logs/logs_test.go create mode 100644 pkg/cmd/options/options.go create mode 100644 pkg/cmd/patch/patch.go create mode 100644 pkg/cmd/patch/patch_test.go create mode 100644 pkg/cmd/plugin/plugin.go create mode 100644 pkg/cmd/plugin/plugin_test.go create mode 100755 pkg/cmd/plugin/testdata/kubectl-foo create mode 100755 pkg/cmd/plugin/testdata/kubectl-version create mode 100644 pkg/cmd/portforward/portforward.go create mode 100644 pkg/cmd/portforward/portforward_test.go create mode 100644 pkg/cmd/profiling.go create mode 100644 pkg/cmd/proxy/proxy.go create mode 100644 pkg/cmd/replace/replace.go create mode 100644 pkg/cmd/replace/replace_test.go create mode 100644 pkg/cmd/rollingupdate/rolling_updater.go create mode 100644 pkg/cmd/rollingupdate/rolling_updater_test.go create mode 100644 pkg/cmd/rollingupdate/rollingupdate.go create mode 100644 pkg/cmd/rollingupdate/rollingupdate_test.go create mode 100644 pkg/cmd/rollout/rollout.go create mode 100644 pkg/cmd/rollout/rollout_history.go create mode 100644 pkg/cmd/rollout/rollout_pause.go create mode 100644 pkg/cmd/rollout/rollout_pause_test.go create mode 100644 pkg/cmd/rollout/rollout_restart.go create mode 100644 pkg/cmd/rollout/rollout_resume.go create mode 100644 pkg/cmd/rollout/rollout_status.go create mode 100644 pkg/cmd/rollout/rollout_undo.go create mode 100644 pkg/cmd/run/run.go create mode 100644 pkg/cmd/run/run_test.go create mode 100644 pkg/cmd/scale/scale.go create mode 100644 pkg/cmd/set/env/doc.go create mode 100644 pkg/cmd/set/env/env_parse.go create mode 100644 pkg/cmd/set/env/env_parse_test.go create mode 100644 pkg/cmd/set/env/env_resolve.go create mode 100644 pkg/cmd/set/helper.go create mode 100644 pkg/cmd/set/set.go create mode 100644 pkg/cmd/set/set_env.go create mode 100644 pkg/cmd/set/set_env_test.go create mode 100644 pkg/cmd/set/set_image.go create mode 100644 pkg/cmd/set/set_image_test.go create mode 100644 pkg/cmd/set/set_resources.go create mode 100644 pkg/cmd/set/set_resources_test.go create mode 100644 pkg/cmd/set/set_selector.go create mode 100644 pkg/cmd/set/set_selector_test.go create mode 100644 pkg/cmd/set/set_serviceaccount.go create mode 100644 pkg/cmd/set/set_serviceaccount_test.go create mode 100644 pkg/cmd/set/set_subject.go create mode 100644 pkg/cmd/set/set_subject_test.go create mode 100644 pkg/cmd/set/set_test.go create mode 100644 pkg/cmd/taint/taint.go create mode 100644 pkg/cmd/taint/taint_test.go create mode 100644 pkg/cmd/taint/utils.go create mode 100644 pkg/cmd/taint/utils_test.go create mode 100644 pkg/cmd/testing/fake.go create mode 100644 pkg/cmd/testing/interfaces.go create mode 100644 pkg/cmd/testing/util.go create mode 100644 pkg/cmd/testing/zz_generated.deepcopy.go create mode 100644 pkg/cmd/top/top.go create mode 100644 pkg/cmd/top/top_node.go create mode 100644 pkg/cmd/top/top_node_test.go create mode 100644 pkg/cmd/top/top_pod.go create mode 100644 pkg/cmd/top/top_pod_test.go create mode 100644 pkg/cmd/top/top_test.go create mode 100644 pkg/cmd/version/version.go create mode 100644 pkg/cmd/wait/wait.go create mode 100644 pkg/cmd/wait/wait_test.go diff --git a/pkg/cmd/alpha.go b/pkg/cmd/alpha.go new file mode 100644 index 000000000..0722a3647 --- /dev/null +++ b/pkg/cmd/alpha.go @@ -0,0 +1,49 @@ +/* +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 cmd + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// NewCmdAlpha creates a command that acts as an alternate root command for features in alpha +func NewCmdAlpha(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "alpha", + Short: i18n.T("Commands for features in alpha"), + Long: templates.LongDesc(i18n.T("These commands correspond to alpha features that are not enabled in Kubernetes clusters by default.")), + } + + // Alpha commands should be added here. As features graduate from alpha they should move + // from here to the CommandGroups defined by NewKubeletCommand() in cmd.go. + //cmd.AddCommand(NewCmdDebug(f, in, out, err)) + + // NewKubeletCommand() will hide the alpha command if it has no subcommands. Overriding + // the help function ensures a reasonable message if someone types the hidden command anyway. + if !cmd.HasSubCommands() { + cmd.SetHelpFunc(func(*cobra.Command, []string) { + cmd.Println(i18n.T("No alpha commands are available in this version of kubectl")) + }) + } + + return cmd +} diff --git a/pkg/cmd/annotate/annotate.go b/pkg/cmd/annotate/annotate.go new file mode 100644 index 000000000..d647f3c60 --- /dev/null +++ b/pkg/cmd/annotate/annotate.go @@ -0,0 +1,382 @@ +/* +Copyright 2014 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 annotate + +import ( + "bytes" + "fmt" + "io" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/json" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// AnnotateOptions have the data required to perform the annotate operation +type AnnotateOptions struct { + PrintFlags *genericclioptions.PrintFlags + PrintObj printers.ResourcePrinterFunc + + // Filename options + resource.FilenameOptions + RecordFlags *genericclioptions.RecordFlags + + // Common user flags + overwrite bool + local bool + dryrun bool + all bool + resourceVersion string + selector string + fieldSelector string + outputFormat string + + // results of arg parsing + resources []string + newAnnotations map[string]string + removeAnnotations []string + Recorder genericclioptions.Recorder + namespace string + enforceNamespace bool + builder *resource.Builder + unstructuredClientForMapping func(mapping *meta.RESTMapping) (resource.RESTClient, error) + + genericclioptions.IOStreams +} + +var ( + annotateLong = templates.LongDesc(` + Update the annotations on one or more resources + + All Kubernetes objects support the ability to store additional data with the object as + annotations. Annotations are key/value pairs that can be larger than labels and include + arbitrary string values such as structured JSON. Tools and system extensions may use + annotations to store their own data. + + Attempting to set an annotation that already exists will fail unless --overwrite is set. + If --resource-version is specified and does not match the current resource version on + the server the command will fail.`) + + annotateExample = templates.Examples(i18n.T(` + # Update pod 'foo' with the annotation 'description' and the value 'my frontend'. + # If the same annotation is set multiple times, only the last value will be applied + kubectl annotate pods foo description='my frontend' + + # Update a pod identified by type and name in "pod.json" + kubectl annotate -f pod.json description='my frontend' + + # Update pod 'foo' with the annotation 'description' and the value 'my frontend running nginx', overwriting any existing value. + kubectl annotate --overwrite pods foo description='my frontend running nginx' + + # Update all pods in the namespace + kubectl annotate pods --all description='my frontend running nginx' + + # Update pod 'foo' only if the resource is unchanged from version 1. + kubectl annotate pods foo description='my frontend running nginx' --resource-version=1 + + # Update pod 'foo' by removing an annotation named 'description' if it exists. + # Does not require the --overwrite flag. + kubectl annotate pods foo description-`)) +) + +// NewAnnotateOptions creates the options for annotate +func NewAnnotateOptions(ioStreams genericclioptions.IOStreams) *AnnotateOptions { + return &AnnotateOptions{ + PrintFlags: genericclioptions.NewPrintFlags("annotated").WithTypeSetter(scheme.Scheme), + + RecordFlags: genericclioptions.NewRecordFlags(), + Recorder: genericclioptions.NoopRecorder{}, + IOStreams: ioStreams, + } +} + +// NewCmdAnnotate creates the `annotate` command +func NewCmdAnnotate(parent string, f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewAnnotateOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "annotate [--overwrite] (-f FILENAME | TYPE NAME) KEY_1=VAL_1 ... KEY_N=VAL_N [--resource-version=version]", + DisableFlagsInUseLine: true, + Short: i18n.T("Update the annotations on a resource"), + Long: annotateLong + "\n\n" + cmdutil.SuggestAPIResources(parent), + Example: annotateExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunAnnotate()) + }, + } + + // bind flag structs + o.RecordFlags.AddFlags(cmd) + o.PrintFlags.AddFlags(cmd) + + cmdutil.AddIncludeUninitializedFlag(cmd) + cmd.Flags().BoolVar(&o.overwrite, "overwrite", o.overwrite, "If true, allow annotations to be overwritten, otherwise reject annotation updates that overwrite existing annotations.") + cmd.Flags().BoolVar(&o.local, "local", o.local, "If true, annotation will NOT contact api-server but run locally.") + cmd.Flags().StringVarP(&o.selector, "selector", "l", o.selector, "Selector (label query) to filter on, not including uninitialized ones, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2).") + cmd.Flags().StringVar(&o.fieldSelector, "field-selector", o.fieldSelector, "Selector (field query) to filter on, supports '=', '==', and '!='.(e.g. --field-selector key1=value1,key2=value2). The server only supports a limited number of field queries per type.") + cmd.Flags().BoolVar(&o.all, "all", o.all, "Select all resources, including uninitialized ones, in the namespace of the specified resource types.") + cmd.Flags().StringVar(&o.resourceVersion, "resource-version", o.resourceVersion, i18n.T("If non-empty, the annotation update will only succeed if this is the current resource-version for the object. Only valid when specifying a single resource.")) + usage := "identifying the resource to update the annotation" + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + cmdutil.AddDryRunFlag(cmd) + + return cmd +} + +// Complete adapts from the command line args and factory to the data required. +func (o *AnnotateOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.outputFormat = cmdutil.GetFlagString(cmd, "output") + o.dryrun = cmdutil.GetDryRunFlag(cmd) + + if o.dryrun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = func(obj runtime.Object, out io.Writer) error { + return printer.PrintObj(obj, out) + } + + o.namespace, o.enforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + o.builder = f.NewBuilder() + o.unstructuredClientForMapping = f.UnstructuredClientForMapping + + // retrieves resource and annotation args from args + // also checks args to verify that all resources are specified before annotations + resources, annotationArgs, err := cmdutil.GetResourcesAndPairs(args, "annotation") + if err != nil { + return err + } + o.resources = resources + o.newAnnotations, o.removeAnnotations, err = parseAnnotations(annotationArgs) + if err != nil { + return err + } + + return nil +} + +// Validate checks to the AnnotateOptions to see if there is sufficient information run the command. +func (o AnnotateOptions) Validate() error { + if o.all && len(o.selector) > 0 { + return fmt.Errorf("cannot set --all and --selector at the same time") + } + if o.all && len(o.fieldSelector) > 0 { + return fmt.Errorf("cannot set --all and --field-selector at the same time") + } + if len(o.resources) < 1 && cmdutil.IsFilenameSliceEmpty(o.Filenames, o.Kustomize) { + return fmt.Errorf("one or more resources must be specified as or /") + } + if len(o.newAnnotations) < 1 && len(o.removeAnnotations) < 1 { + return fmt.Errorf("at least one annotation update is required") + } + return validateAnnotations(o.removeAnnotations, o.newAnnotations) +} + +// RunAnnotate does the work +func (o AnnotateOptions) RunAnnotate() error { + b := o.builder. + Unstructured(). + LocalParam(o.local). + ContinueOnError(). + NamespaceParam(o.namespace).DefaultNamespace(). + FilenameParam(o.enforceNamespace, &o.FilenameOptions). + Flatten() + + if !o.local { + b = b.LabelSelectorParam(o.selector). + FieldSelectorParam(o.fieldSelector). + ResourceTypeOrNameArgs(o.all, o.resources...). + Latest() + } + + r := b.Do() + if err := r.Err(); err != nil { + return err + } + + var singleItemImpliedResource bool + r.IntoSingleItemImplied(&singleItemImpliedResource) + + // only apply resource version locking on a single resource. + // we must perform this check after o.builder.Do() as + // []o.resources can not accurately return the proper number + // of resources when they are not passed in "resource/name" format. + if !singleItemImpliedResource && len(o.resourceVersion) > 0 { + return fmt.Errorf("--resource-version may only be used with a single resource") + } + + return r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + var outputObj runtime.Object + obj := info.Object + + if o.dryrun || o.local { + if err := o.updateAnnotations(obj); err != nil { + return err + } + outputObj = obj + } else { + name, namespace := info.Name, info.Namespace + oldData, err := json.Marshal(obj) + if err != nil { + return err + } + if err := o.Recorder.Record(info.Object); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + if err := o.updateAnnotations(obj); err != nil { + return err + } + newData, err := json.Marshal(obj) + if err != nil { + return err + } + patchBytes, err := jsonpatch.CreateMergePatch(oldData, newData) + createdPatch := err == nil + if err != nil { + klog.V(2).Infof("couldn't compute patch: %v", err) + } + + mapping := info.ResourceMapping() + client, err := o.unstructuredClientForMapping(mapping) + if err != nil { + return err + } + helper := resource.NewHelper(client, mapping) + + if createdPatch { + outputObj, err = helper.Patch(namespace, name, types.MergePatchType, patchBytes, nil) + } else { + outputObj, err = helper.Replace(namespace, name, false, obj) + } + if err != nil { + return err + } + } + + return o.PrintObj(outputObj, o.Out) + }) +} + +// parseAnnotations retrieves new and remove annotations from annotation args +func parseAnnotations(annotationArgs []string) (map[string]string, []string, error) { + return cmdutil.ParsePairs(annotationArgs, "annotation", true) +} + +// validateAnnotations checks the format of annotation args and checks removed annotations aren't in the new annotations map +func validateAnnotations(removeAnnotations []string, newAnnotations map[string]string) error { + var modifyRemoveBuf bytes.Buffer + for _, removeAnnotation := range removeAnnotations { + if _, found := newAnnotations[removeAnnotation]; found { + if modifyRemoveBuf.Len() > 0 { + modifyRemoveBuf.WriteString(", ") + } + modifyRemoveBuf.WriteString(fmt.Sprintf(removeAnnotation)) + } + } + if modifyRemoveBuf.Len() > 0 { + return fmt.Errorf("can not both modify and remove the following annotation(s) in the same command: %s", modifyRemoveBuf.String()) + } + + return nil +} + +// validateNoAnnotationOverwrites validates that when overwrite is false, to-be-updated annotations don't exist in the object annotation map (yet) +func validateNoAnnotationOverwrites(accessor metav1.Object, annotations map[string]string) error { + var buf bytes.Buffer + for key := range annotations { + // change-cause annotation can always be overwritten + if key == polymorphichelpers.ChangeCauseAnnotation { + continue + } + if value, found := accessor.GetAnnotations()[key]; found { + if buf.Len() > 0 { + buf.WriteString("; ") + } + buf.WriteString(fmt.Sprintf("'%s' already has a value (%s)", key, value)) + } + } + if buf.Len() > 0 { + return fmt.Errorf("--overwrite is false but found the following declared annotation(s): %s", buf.String()) + } + return nil +} + +// updateAnnotations updates annotations of obj +func (o AnnotateOptions) updateAnnotations(obj runtime.Object) error { + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + if !o.overwrite { + if err := validateNoAnnotationOverwrites(accessor, o.newAnnotations); err != nil { + return err + } + } + + annotations := accessor.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + + for key, value := range o.newAnnotations { + annotations[key] = value + } + for _, annotation := range o.removeAnnotations { + delete(annotations, annotation) + } + accessor.SetAnnotations(annotations) + + if len(o.resourceVersion) != 0 { + accessor.SetResourceVersion(o.resourceVersion) + } + return nil +} diff --git a/pkg/cmd/annotate/annotate_test.go b/pkg/cmd/annotate/annotate_test.go new file mode 100644 index 000000000..92c0e4179 --- /dev/null +++ b/pkg/cmd/annotate/annotate_test.go @@ -0,0 +1,643 @@ +/* +Copyright 2014 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 annotate + +import ( + "net/http" + "reflect" + "strings" + "testing" + + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestValidateAnnotationOverwrites(t *testing.T) { + tests := []struct { + meta *metav1.ObjectMeta + annotations map[string]string + expectErr bool + scenario string + }{ + { + meta: &metav1.ObjectMeta{ + Annotations: map[string]string{ + "a": "A", + "b": "B", + }, + }, + annotations: map[string]string{ + "a": "a", + "c": "C", + }, + scenario: "share first annotation", + expectErr: true, + }, + { + meta: &metav1.ObjectMeta{ + Annotations: map[string]string{ + "a": "A", + "c": "C", + }, + }, + annotations: map[string]string{ + "b": "B", + "c": "c", + }, + scenario: "share second annotation", + expectErr: true, + }, + { + meta: &metav1.ObjectMeta{ + Annotations: map[string]string{ + "a": "A", + "c": "C", + }, + }, + annotations: map[string]string{ + "b": "B", + "d": "D", + }, + scenario: "no overlap", + }, + { + meta: &metav1.ObjectMeta{}, + annotations: map[string]string{ + "a": "A", + "b": "B", + }, + scenario: "no annotations", + }, + } + for _, test := range tests { + err := validateNoAnnotationOverwrites(test.meta, test.annotations) + if test.expectErr && err == nil { + t.Errorf("%s: unexpected non-error", test.scenario) + } else if !test.expectErr && err != nil { + t.Errorf("%s: unexpected error: %v", test.scenario, err) + } + } +} + +func TestParseAnnotations(t *testing.T) { + testURL := "https://test.com/index.htm?id=123#u=user-name" + testJSON := `'{"kind":"SerializedReference","apiVersion":"v1","reference":{"kind":"ReplicationController","namespace":"default","name":"my-nginx","uid":"c544ee78-2665-11e5-8051-42010af0c213","apiVersion":"v1","resourceVersion":"61368"}}'` + tests := []struct { + annotations []string + expected map[string]string + expectedRemove []string + scenario string + expectedErr string + expectErr bool + }{ + { + annotations: []string{"a=b", "c=d"}, + expected: map[string]string{"a": "b", "c": "d"}, + expectedRemove: []string{}, + scenario: "add two annotations", + expectErr: false, + }, + { + annotations: []string{"url=" + testURL, "fake.kubernetes.io/annotation=" + testJSON}, + expected: map[string]string{"url": testURL, "fake.kubernetes.io/annotation": testJSON}, + expectedRemove: []string{}, + scenario: "add annotations with special characters", + expectErr: false, + }, + { + annotations: []string{}, + expected: map[string]string{}, + expectedRemove: []string{}, + scenario: "add no annotations", + expectErr: false, + }, + { + annotations: []string{"a=b", "c=d", "e-"}, + expected: map[string]string{"a": "b", "c": "d"}, + expectedRemove: []string{"e"}, + scenario: "add two annotations, remove one", + expectErr: false, + }, + { + annotations: []string{"ab", "c=d"}, + expectedErr: "invalid annotation format: ab", + scenario: "incorrect annotation input (missing =value)", + expectErr: true, + }, + { + annotations: []string{"a="}, + expected: map[string]string{"a": ""}, + expectedRemove: []string{}, + scenario: "add valid annotation with empty value", + expectErr: false, + }, + { + annotations: []string{"ab", "a="}, + expectedErr: "invalid annotation format: ab", + scenario: "incorrect annotation input (missing =value)", + expectErr: true, + }, + { + annotations: []string{"-"}, + expectedErr: "invalid annotation format: -", + scenario: "incorrect annotation input (missing key)", + expectErr: true, + }, + { + annotations: []string{"=bar"}, + expectedErr: "invalid annotation format: =bar", + scenario: "incorrect annotation input (missing key)", + expectErr: true, + }, + } + for _, test := range tests { + annotations, remove, err := parseAnnotations(test.annotations) + switch { + case test.expectErr && err == nil: + t.Errorf("%s: unexpected non-error, should return %v", test.scenario, test.expectedErr) + case test.expectErr && err.Error() != test.expectedErr: + t.Errorf("%s: unexpected error %v, expected %v", test.scenario, err, test.expectedErr) + case !test.expectErr && err != nil: + t.Errorf("%s: unexpected error %v", test.scenario, err) + case !test.expectErr && !reflect.DeepEqual(annotations, test.expected): + t.Errorf("%s: expected %v, got %v", test.scenario, test.expected, annotations) + case !test.expectErr && !reflect.DeepEqual(remove, test.expectedRemove): + t.Errorf("%s: expected %v, got %v", test.scenario, test.expectedRemove, remove) + } + } +} + +func TestValidateAnnotations(t *testing.T) { + tests := []struct { + removeAnnotations []string + newAnnotations map[string]string + expectedErr string + scenario string + }{ + { + expectedErr: "can not both modify and remove the following annotation(s) in the same command: a", + removeAnnotations: []string{"a"}, + newAnnotations: map[string]string{"a": "b", "c": "d"}, + scenario: "remove an added annotation", + }, + { + expectedErr: "can not both modify and remove the following annotation(s) in the same command: a, c", + removeAnnotations: []string{"a", "c"}, + newAnnotations: map[string]string{"a": "b", "c": "d"}, + scenario: "remove added annotations", + }, + } + for _, test := range tests { + if err := validateAnnotations(test.removeAnnotations, test.newAnnotations); err == nil { + t.Errorf("%s: unexpected non-error", test.scenario) + } else if err.Error() != test.expectedErr { + t.Errorf("%s: expected error %s, got %s", test.scenario, test.expectedErr, err.Error()) + } + } +} + +func TestUpdateAnnotations(t *testing.T) { + tests := []struct { + obj runtime.Object + overwrite bool + version string + annotations map[string]string + remove []string + expected runtime.Object + expectErr bool + }{ + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + annotations: map[string]string{"a": "b"}, + expectErr: true, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + annotations: map[string]string{"a": "c"}, + overwrite: true, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "c"}, + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + annotations: map[string]string{"c": "d"}, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b", "c": "d"}, + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + annotations: map[string]string{"c": "d"}, + version: "2", + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b", "c": "d"}, + ResourceVersion: "2", + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + annotations: map[string]string{}, + remove: []string{"a"}, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b", "c": "d"}, + }, + }, + annotations: map[string]string{"e": "f"}, + remove: []string{"a"}, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "c": "d", + "e": "f", + }, + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b", "c": "d"}, + }, + }, + annotations: map[string]string{"e": "f"}, + remove: []string{"g"}, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "a": "b", + "c": "d", + "e": "f", + }, + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b", "c": "d"}, + }, + }, + remove: []string{"e"}, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "a": "b", + "c": "d", + }, + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{}, + }, + annotations: map[string]string{"a": "b"}, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"a": "b"}, + }, + }, + }, + } + for _, test := range tests { + options := &AnnotateOptions{ + overwrite: test.overwrite, + newAnnotations: test.annotations, + removeAnnotations: test.remove, + resourceVersion: test.version, + } + err := options.updateAnnotations(test.obj) + if test.expectErr { + if err == nil { + t.Errorf("unexpected non-error: %v", test) + } + continue + } + if !test.expectErr && err != nil { + t.Errorf("unexpected error: %v %v", err, test) + } + if !reflect.DeepEqual(test.obj, test.expected) { + t.Errorf("expected: %v, got %v", test.expected, test.obj) + } + } +} + +func TestAnnotateErrors(t *testing.T) { + testCases := map[string]struct { + args []string + flags map[string]string + errFn func(error) bool + }{ + "no args": { + args: []string{}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "one or more resources must be specified") }, + }, + "not enough annotations": { + args: []string{"pods"}, + errFn: func(err error) bool { + return strings.Contains(err.Error(), "at least one annotation update is required") + }, + }, + "wrong annotations": { + args: []string{"pods", "-"}, + errFn: func(err error) bool { + return strings.Contains(err.Error(), "at least one annotation update is required") + }, + }, + "wrong annotations 2": { + args: []string{"pods", "=bar"}, + errFn: func(err error) bool { + return strings.Contains(err.Error(), "at least one annotation update is required") + }, + }, + "no resources remove annotations": { + args: []string{"pods-"}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "one or more resources must be specified") }, + }, + "no resources add annotations": { + args: []string{"pods=bar"}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "one or more resources must be specified") }, + }, + } + + for k, testCase := range testCases { + t.Run(k, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + iostreams, _, bufOut, bufErr := genericclioptions.NewTestIOStreams() + cmd := NewCmdAnnotate("kubectl", tf, iostreams) + cmd.SetOutput(bufOut) + + for k, v := range testCase.flags { + cmd.Flags().Set(k, v) + } + options := NewAnnotateOptions(iostreams) + err := options.Complete(tf, cmd, testCase.args) + if err == nil { + err = options.Validate() + } + if !testCase.errFn(err) { + t.Errorf("%s: unexpected error: %v", k, err) + return + } + if bufOut.Len() > 0 { + t.Errorf("buffer should be empty: %s", string(bufOut.Bytes())) + } + if bufErr.Len() > 0 { + t.Errorf("buffer should be empty: %s", string(bufErr.Bytes())) + } + }) + } +} + +func TestAnnotateObject(t *testing.T) { + pods, _, _ := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "testgroup", Version: "v1"}, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch req.Method { + case "GET": + switch req.URL.Path { + case "/namespaces/test/pods/foo": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + case "PATCH": + switch req.URL.Path { + case "/namespaces/test/pods/foo": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + default: + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + iostreams, _, bufOut, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdAnnotate("kubectl", tf, iostreams) + cmd.SetOutput(bufOut) + options := NewAnnotateOptions(iostreams) + args := []string{"pods/foo", "a=b", "c-"} + if err := options.Complete(tf, cmd, args); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.Validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.RunAnnotate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAnnotateObjectFromFile(t *testing.T) { + pods, _, _ := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "testgroup", Version: "v1"}, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch req.Method { + case "GET": + switch req.URL.Path { + case "/namespaces/test/replicationcontrollers/cassandra": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + case "PATCH": + switch req.URL.Path { + case "/namespaces/test/replicationcontrollers/cassandra": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + default: + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + iostreams, _, bufOut, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdAnnotate("kubectl", tf, iostreams) + cmd.SetOutput(bufOut) + options := NewAnnotateOptions(iostreams) + options.Filenames = []string{"../../../../test/e2e/testing-manifests/statefulset/cassandra/controller.yaml"} + args := []string{"a=b", "c-"} + if err := options.Complete(tf, cmd, args); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.Validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.RunAnnotate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAnnotateLocal(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "testgroup", Version: "v1"}, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + iostreams, _, _, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdAnnotate("kubectl", tf, iostreams) + options := NewAnnotateOptions(iostreams) + options.local = true + options.Filenames = []string{"../../../../test/e2e/testing-manifests/statefulset/cassandra/controller.yaml"} + args := []string{"a=b"} + if err := options.Complete(tf, cmd, args); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.Validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.RunAnnotate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAnnotateMultipleObjects(t *testing.T) { + pods, _, _ := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + tf.UnstructuredClient = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "testgroup", Version: "v1"}, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch req.Method { + case "GET": + switch req.URL.Path { + case "/namespaces/test/pods": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, pods)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + case "PATCH": + switch req.URL.Path { + case "/namespaces/test/pods/foo": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + case "/namespaces/test/pods/bar": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[1])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + default: + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + iostreams, _, _, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdAnnotate("kubectl", tf, iostreams) + cmd.SetOutput(iostreams.Out) + options := NewAnnotateOptions(iostreams) + options.all = true + args := []string{"pods", "a=b", "c-"} + if err := options.Complete(tf, cmd, args); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.Validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := options.RunAnnotate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/cmd/apiresources/apiresources.go b/pkg/cmd/apiresources/apiresources.go new file mode 100644 index 000000000..aa558e75f --- /dev/null +++ b/pkg/cmd/apiresources/apiresources.go @@ -0,0 +1,248 @@ +/* +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 apiresources + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/printers" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + apiresourcesExample = templates.Examples(` + # Print the supported API Resources + kubectl api-resources + + # Print the supported API Resources with more information + kubectl api-resources -o wide + + # Print the supported namespaced resources + kubectl api-resources --namespaced=true + + # Print the supported non-namespaced resources + kubectl api-resources --namespaced=false + + # Print the supported API Resources with specific APIGroup + kubectl api-resources --api-group=extensions`) +) + +// APIResourceOptions is the start of the data required to perform the operation. +// As new fields are added, add them here instead of referencing the cmd.Flags() +type APIResourceOptions struct { + Output string + APIGroup string + Namespaced bool + Verbs []string + NoHeaders bool + Cached bool + + genericclioptions.IOStreams +} + +// groupResource contains the APIGroup and APIResource +type groupResource struct { + APIGroup string + APIResource metav1.APIResource +} + +// NewAPIResourceOptions creates the options for APIResource +func NewAPIResourceOptions(ioStreams genericclioptions.IOStreams) *APIResourceOptions { + return &APIResourceOptions{ + IOStreams: ioStreams, + Namespaced: true, + } +} + +// NewCmdAPIResources creates the `api-resources` command +func NewCmdAPIResources(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewAPIResourceOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "api-resources", + Short: "Print the supported API resources on the server", + Long: "Print the supported API resources on the server", + Example: apiresourcesExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunAPIResources(cmd, f)) + }, + } + + cmd.Flags().BoolVar(&o.NoHeaders, "no-headers", o.NoHeaders, "When using the default or custom-column output format, don't print headers (default print headers).") + cmd.Flags().StringVarP(&o.Output, "output", "o", o.Output, "Output format. One of: wide|name.") + + cmd.Flags().StringVar(&o.APIGroup, "api-group", o.APIGroup, "Limit to resources in the specified API group.") + cmd.Flags().BoolVar(&o.Namespaced, "namespaced", o.Namespaced, "If false, non-namespaced resources will be returned, otherwise returning namespaced resources by default.") + cmd.Flags().StringSliceVar(&o.Verbs, "verbs", o.Verbs, "Limit to resources that support the specified verbs.") + cmd.Flags().BoolVar(&o.Cached, "cached", o.Cached, "Use the cached list of resources if available.") + return cmd +} + +// Validate checks to the APIResourceOptions to see if there is sufficient information run the command +func (o *APIResourceOptions) Validate() error { + supportedOutputTypes := sets.NewString("", "wide", "name") + if !supportedOutputTypes.Has(o.Output) { + return fmt.Errorf("--output %v is not available", o.Output) + } + return nil +} + +// Complete adapts from the command line args and validates them +func (o *APIResourceOptions) Complete(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "unexpected arguments: %v", args) + } + return nil +} + +// RunAPIResources does the work +func (o *APIResourceOptions) RunAPIResources(cmd *cobra.Command, f cmdutil.Factory) error { + w := printers.GetNewTabWriter(o.Out) + defer w.Flush() + + discoveryclient, err := f.ToDiscoveryClient() + if err != nil { + return err + } + + if !o.Cached { + // Always request fresh data from the server + discoveryclient.Invalidate() + } + + errs := []error{} + lists, err := discoveryclient.ServerPreferredResources() + if err != nil { + errs = append(errs, err) + } + + resources := []groupResource{} + + groupChanged := cmd.Flags().Changed("api-group") + nsChanged := cmd.Flags().Changed("namespaced") + + for _, list := range lists { + if len(list.APIResources) == 0 { + continue + } + gv, err := schema.ParseGroupVersion(list.GroupVersion) + if err != nil { + continue + } + for _, resource := range list.APIResources { + if len(resource.Verbs) == 0 { + continue + } + // filter apiGroup + if groupChanged && o.APIGroup != gv.Group { + continue + } + // filter namespaced + if nsChanged && o.Namespaced != resource.Namespaced { + continue + } + // filter to resources that support the specified verbs + if len(o.Verbs) > 0 && !sets.NewString(resource.Verbs...).HasAll(o.Verbs...) { + continue + } + resources = append(resources, groupResource{ + APIGroup: gv.Group, + APIResource: resource, + }) + } + } + + if o.NoHeaders == false && o.Output != "name" { + if err = printContextHeaders(w, o.Output); err != nil { + return err + } + } + + sort.Stable(sortableGroupResource(resources)) + for _, r := range resources { + switch o.Output { + case "name": + name := r.APIResource.Name + if len(r.APIGroup) > 0 { + name += "." + r.APIGroup + } + if _, err := fmt.Fprintf(w, "%s\n", name); err != nil { + errs = append(errs, err) + } + case "wide": + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%s\t%v\n", + r.APIResource.Name, + strings.Join(r.APIResource.ShortNames, ","), + r.APIGroup, + r.APIResource.Namespaced, + r.APIResource.Kind, + r.APIResource.Verbs); err != nil { + errs = append(errs, err) + } + case "": + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%s\n", + r.APIResource.Name, + strings.Join(r.APIResource.ShortNames, ","), + r.APIGroup, + r.APIResource.Namespaced, + r.APIResource.Kind); err != nil { + errs = append(errs, err) + } + } + } + + if len(errs) > 0 { + return errors.NewAggregate(errs) + } + return nil +} + +func printContextHeaders(out io.Writer, output string) error { + columnNames := []string{"NAME", "SHORTNAMES", "APIGROUP", "NAMESPACED", "KIND"} + if output == "wide" { + columnNames = append(columnNames, "VERBS") + } + _, err := fmt.Fprintf(out, "%s\n", strings.Join(columnNames, "\t")) + return err +} + +type sortableGroupResource []groupResource + +func (s sortableGroupResource) Len() int { return len(s) } +func (s sortableGroupResource) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s sortableGroupResource) Less(i, j int) bool { + ret := strings.Compare(s[i].APIGroup, s[j].APIGroup) + if ret > 0 { + return false + } else if ret == 0 { + return strings.Compare(s[i].APIResource.Name, s[j].APIResource.Name) < 0 + } + return true +} diff --git a/pkg/cmd/apiresources/apiversions.go b/pkg/cmd/apiresources/apiversions.go new file mode 100644 index 000000000..06ec98d71 --- /dev/null +++ b/pkg/cmd/apiresources/apiversions.go @@ -0,0 +1,97 @@ +/* +Copyright 2014 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 apiresources + +import ( + "fmt" + "sort" + + "github.com/spf13/cobra" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/discovery" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + apiversionsExample = templates.Examples(i18n.T(` + # Print the supported API versions + kubectl api-versions`)) +) + +// APIVersionsOptions have the data required for API versions +type APIVersionsOptions struct { + discoveryClient discovery.CachedDiscoveryInterface + + genericclioptions.IOStreams +} + +// NewAPIVersionsOptions creates the options for APIVersions +func NewAPIVersionsOptions(ioStreams genericclioptions.IOStreams) *APIVersionsOptions { + return &APIVersionsOptions{ + IOStreams: ioStreams, + } +} + +// NewCmdAPIVersions creates the `api-versions` command +func NewCmdAPIVersions(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewAPIVersionsOptions(ioStreams) + cmd := &cobra.Command{ + Use: "api-versions", + Short: "Print the supported API versions on the server, in the form of \"group/version\"", + Long: "Print the supported API versions on the server, in the form of \"group/version\"", + Example: apiversionsExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.RunAPIVersions()) + }, + } + return cmd +} + +// Complete adapts from the command line args and factory to the data required +func (o *APIVersionsOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "unexpected arguments: %v", args) + } + var err error + o.discoveryClient, err = f.ToDiscoveryClient() + if err != nil { + return err + } + return nil +} + +// RunAPIVersions does the work +func (o *APIVersionsOptions) RunAPIVersions() error { + // Always request fresh data from the server + o.discoveryClient.Invalidate() + + groupList, err := o.discoveryClient.ServerGroups() + if err != nil { + return fmt.Errorf("couldn't get available api versions from server: %v", err) + } + apiVersions := metav1.ExtractGroupVersions(groupList) + sort.Strings(apiVersions) + for _, v := range apiVersions { + fmt.Fprintln(o.Out, v) + } + return nil +} diff --git a/pkg/cmd/apply/apply.go b/pkg/cmd/apply/apply.go new file mode 100644 index 000000000..1eed94a05 --- /dev/null +++ b/pkg/cmd/apply/apply.go @@ -0,0 +1,997 @@ +/* +Copyright 2014 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 apply + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/jonboulle/clockwork" + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/jsonmergepatch" + "k8s.io/apimachinery/pkg/util/mergepatch" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/klog" + oapi "k8s.io/kube-openapi/pkg/util/proto" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/openapi" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubectl/pkg/validation" + "k8s.io/kubernetes/pkg/kubectl/cmd/delete" +) + +// ApplyOptions defines flags and other configuration parameters for the `apply` command +type ApplyOptions struct { + RecordFlags *genericclioptions.RecordFlags + Recorder genericclioptions.Recorder + + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + DeleteFlags *delete.DeleteFlags + DeleteOptions *delete.DeleteOptions + + ServerSideApply bool + ForceConflicts bool + FieldManager string + Selector string + DryRun bool + ServerDryRun bool + Prune bool + PruneResources []pruneResource + cmdBaseName string + All bool + Overwrite bool + OpenAPIPatch bool + PruneWhitelist []string + + Validator validation.Schema + Builder *resource.Builder + Mapper meta.RESTMapper + DynamicClient dynamic.Interface + DiscoveryClient discovery.DiscoveryInterface + OpenAPISchema openapi.Resources + + Namespace string + EnforceNamespace bool + + genericclioptions.IOStreams +} + +const ( + // maxPatchRetry is the maximum number of conflicts retry for during a patch operation before returning failure + maxPatchRetry = 5 + // backOffPeriod is the period to back off when apply patch results in error. + backOffPeriod = 1 * time.Second + // how many times we can retry before back off + triesBeforeBackOff = 1 +) + +var ( + applyLong = templates.LongDesc(i18n.T(` + Apply a configuration to a resource by filename or stdin. + The resource name must be specified. This resource will be created if it doesn't exist yet. + To use 'apply', always create the resource initially with either 'apply' or 'create --save-config'. + + JSON and YAML formats are accepted. + + Alpha Disclaimer: the --prune functionality is not yet complete. Do not use unless you are aware of what the current state is. See https://issues.k8s.io/34274.`)) + + applyExample = templates.Examples(i18n.T(` + # Apply the configuration in pod.json to a pod. + kubectl apply -f ./pod.json + + # Apply resources from a directory containing kustomization.yaml - e.g. dir/kustomization.yaml. + kubectl apply -k dir/ + + # Apply the JSON passed into stdin to a pod. + cat pod.json | kubectl apply -f - + + # Note: --prune is still in Alpha + # Apply the configuration in manifest.yaml that matches label app=nginx and delete all the other resources that are not in the file and match label app=nginx. + kubectl apply --prune -f manifest.yaml -l app=nginx + + # Apply the configuration in manifest.yaml and delete all the other configmaps that are not in the file. + kubectl apply --prune -f manifest.yaml --all --prune-whitelist=core/v1/ConfigMap`)) + + warningNoLastAppliedConfigAnnotation = "Warning: %[1]s apply should be used on resource created by either %[1]s create --save-config or %[1]s apply\n" +) + +// NewApplyOptions creates new ApplyOptions for the `apply` command +func NewApplyOptions(ioStreams genericclioptions.IOStreams) *ApplyOptions { + return &ApplyOptions{ + RecordFlags: genericclioptions.NewRecordFlags(), + DeleteFlags: delete.NewDeleteFlags("that contains the configuration to apply"), + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + + Overwrite: true, + OpenAPIPatch: true, + + Recorder: genericclioptions.NoopRecorder{}, + + IOStreams: ioStreams, + } +} + +// NewCmdApply creates the `apply` command +func NewCmdApply(baseName string, f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewApplyOptions(ioStreams) + + // Store baseName for use in printing warnings / messages involving the base command name. + // This is useful for downstream command that wrap this one. + o.cmdBaseName = baseName + + cmd := &cobra.Command{ + Use: "apply (-f FILENAME | -k DIRECTORY)", + DisableFlagsInUseLine: true, + Short: i18n.T("Apply a configuration to a resource by filename or stdin"), + Long: applyLong, + Example: applyExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(validateArgs(cmd, args)) + cmdutil.CheckErr(validatePruneAll(o.Prune, o.All, o.Selector)) + cmdutil.CheckErr(o.Run()) + }, + } + + // bind flag structs + o.DeleteFlags.AddFlags(cmd) + o.RecordFlags.AddFlags(cmd) + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().BoolVar(&o.Overwrite, "overwrite", o.Overwrite, "Automatically resolve conflicts between the modified and live configuration by using values from the modified configuration") + cmd.Flags().BoolVar(&o.Prune, "prune", o.Prune, "Automatically delete resource objects, including the uninitialized ones, that do not appear in the configs and are created by either apply or create --save-config. Should be used with either -l or --all.") + cmdutil.AddValidateFlags(cmd) + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().BoolVar(&o.All, "all", o.All, "Select all resources in the namespace of the specified resource types.") + cmd.Flags().StringArrayVar(&o.PruneWhitelist, "prune-whitelist", o.PruneWhitelist, "Overwrite the default whitelist with for --prune") + cmd.Flags().BoolVar(&o.OpenAPIPatch, "openapi-patch", o.OpenAPIPatch, "If true, use openapi to calculate diff when the openapi presents and the resource can be found in the openapi spec. Otherwise, fall back to use baked-in types.") + cmd.Flags().BoolVar(&o.ServerDryRun, "server-dry-run", o.ServerDryRun, "If true, request will be sent to server with dry-run flag, which means the modifications won't be persisted. This is an alpha feature and flag.") + cmd.Flags().Bool("dry-run", false, "If true, only print the object that would be sent, without sending it. Warning: --dry-run cannot accurately output the result of merging the local manifest and the server-side data. Use --server-dry-run to get the merged result instead.") + cmdutil.AddIncludeUninitializedFlag(cmd) + cmdutil.AddServerSideApplyFlags(cmd) + + // apply subcommands + cmd.AddCommand(NewCmdApplyViewLastApplied(f, ioStreams)) + cmd.AddCommand(NewCmdApplySetLastApplied(f, ioStreams)) + cmd.AddCommand(NewCmdApplyEditLastApplied(f, ioStreams)) + + return cmd +} + +// Complete verifies if ApplyOptions are valid and without conflicts. +func (o *ApplyOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + o.ServerSideApply = cmdutil.GetServerSideApplyFlag(cmd) + o.ForceConflicts = cmdutil.GetForceConflictsFlag(cmd) + o.FieldManager = cmdutil.GetFieldManagerFlag(cmd) + o.DryRun = cmdutil.GetDryRunFlag(cmd) + + if o.ForceConflicts && !o.ServerSideApply { + return fmt.Errorf("--experimental-force-conflicts only works with --experimental-server-side") + } + + if o.DryRun && o.ServerSideApply { + return fmt.Errorf("--dry-run doesn't work with --experimental-server-side (did you mean --server-dry-run instead?)") + } + + if o.DryRun && o.ServerDryRun { + return fmt.Errorf("--dry-run and --server-dry-run can't be used together") + } + + // allow for a success message operation to be specified at print time + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + if o.ServerDryRun { + o.PrintFlags.Complete("%s (server dry run)") + } + return o.PrintFlags.ToPrinter() + } + + var err error + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.DiscoveryClient, err = f.ToDiscoveryClient() + if err != nil { + return err + } + + dynamicClient, err := f.DynamicClient() + if err != nil { + return err + } + o.DeleteOptions = o.DeleteFlags.ToOptions(dynamicClient, o.IOStreams) + err = o.DeleteOptions.FilenameOptions.RequireFilenameOrKustomize() + if err != nil { + return err + } + + o.OpenAPISchema, _ = f.OpenAPISchema() + o.Validator, err = f.Validator(cmdutil.GetFlagBool(cmd, "validate")) + o.Builder = f.NewBuilder() + o.Mapper, err = f.ToRESTMapper() + if err != nil { + return err + } + + o.DynamicClient, err = f.DynamicClient() + if err != nil { + return err + } + + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + return nil +} + +func validateArgs(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args) + } + return nil +} + +func validatePruneAll(prune, all bool, selector string) error { + if all && len(selector) > 0 { + return fmt.Errorf("cannot set --all and --selector at the same time") + } + if prune && !all && selector == "" { + return fmt.Errorf("all resources selected for prune without explicitly passing --all. To prune all resources, pass the --all flag. If you did not mean to prune all resources, specify a label selector") + } + return nil +} + +func parsePruneResources(mapper meta.RESTMapper, gvks []string) ([]pruneResource, error) { + pruneResources := []pruneResource{} + for _, groupVersionKind := range gvks { + gvk := strings.Split(groupVersionKind, "/") + if len(gvk) != 3 { + return nil, fmt.Errorf("invalid GroupVersionKind format: %v, please follow ", groupVersionKind) + } + + if gvk[0] == "core" { + gvk[0] = "" + } + mapping, err := mapper.RESTMapping(schema.GroupKind{Group: gvk[0], Kind: gvk[2]}, gvk[1]) + if err != nil { + return pruneResources, err + } + var namespaced bool + namespaceScope := mapping.Scope.Name() + switch namespaceScope { + case meta.RESTScopeNameNamespace: + namespaced = true + case meta.RESTScopeNameRoot: + namespaced = false + default: + return pruneResources, fmt.Errorf("Unknown namespace scope: %q", namespaceScope) + } + + pruneResources = append(pruneResources, pruneResource{gvk[0], gvk[1], gvk[2], namespaced}) + } + return pruneResources, nil +} + +func isIncompatibleServerError(err error) bool { + // 415: Unsupported media type means we're talking to a server which doesn't + // support server-side apply. + if _, ok := err.(*errors.StatusError); !ok { + // Non-StatusError means the error isn't because the server is incompatible. + return false + } + return err.(*errors.StatusError).Status().Code == http.StatusUnsupportedMediaType +} + +// Run executes the `apply` command. +func (o *ApplyOptions) Run() error { + var openapiSchema openapi.Resources + if o.OpenAPIPatch { + openapiSchema = o.OpenAPISchema + } + + dryRunVerifier := &DryRunVerifier{ + Finder: cmdutil.NewCRDFinder(cmdutil.CRDFromDynamic(o.DynamicClient)), + OpenAPIGetter: o.DiscoveryClient, + } + + // include the uninitialized objects by default if --prune is true + // unless explicitly set --include-uninitialized=false + r := o.Builder. + Unstructured(). + Schema(o.Validator). + ContinueOnError(). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.DeleteOptions.FilenameOptions). + LabelSelectorParam(o.Selector). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + var err error + if o.Prune { + o.PruneResources, err = parsePruneResources(o.Mapper, o.PruneWhitelist) + if err != nil { + return err + } + } + + output := *o.PrintFlags.OutputFormat + shortOutput := output == "name" + + visitedUids := sets.NewString() + visitedNamespaces := sets.NewString() + + var objs []runtime.Object + + count := 0 + err = r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + // If server-dry-run is requested but the type doesn't support it, fail right away. + if o.ServerDryRun { + if err := dryRunVerifier.HasSupport(info.Mapping.GroupVersionKind); err != nil { + return err + } + } + + if info.Namespaced() { + visitedNamespaces.Insert(info.Namespace) + } + + if err := o.Recorder.Record(info.Object); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + if o.ServerSideApply { + // Send the full object to be applied on the server side. + data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, info.Object) + if err != nil { + return cmdutil.AddSourceToErr("serverside-apply", info.Source, err) + } + + options := metav1.PatchOptions{ + Force: &o.ForceConflicts, + FieldManager: o.FieldManager, + } + if o.ServerDryRun { + options.DryRun = []string{metav1.DryRunAll} + } + + obj, err := resource.NewHelper(info.Client, info.Mapping).Patch( + info.Namespace, + info.Name, + types.ApplyPatchType, + data, + &options, + ) + if err != nil { + if isIncompatibleServerError(err) { + err = fmt.Errorf("Server-side apply not available on the server: (%v)", err) + } + + return err + } + + info.Refresh(obj, true) + metadata, err := meta.Accessor(info.Object) + if err != nil { + return err + } + + visitedUids.Insert(string(metadata.GetUID())) + count++ + if len(output) > 0 && !shortOutput { + objs = append(objs, info.Object) + return nil + } + + printer, err := o.ToPrinter("serverside-applied") + if err != nil { + return err + } + + return printer.PrintObj(info.Object, o.Out) + } + + // Get the modified configuration of the object. Embed the result + // as an annotation in the modified configuration, so that it will appear + // in the patch sent to the server. + modified, err := util.GetModifiedConfiguration(info.Object, true, unstructured.UnstructuredJSONScheme) + if err != nil { + return cmdutil.AddSourceToErr(fmt.Sprintf("retrieving modified configuration from:\n%s\nfor:", info.String()), info.Source, err) + } + + // Print object only if output format other than "name" is specified + printObject := len(output) > 0 && !shortOutput + + if err := info.Get(); err != nil { + if !errors.IsNotFound(err) { + return cmdutil.AddSourceToErr(fmt.Sprintf("retrieving current configuration of:\n%s\nfrom server for:", info.String()), info.Source, err) + } + + // Create the resource if it doesn't exist + // First, update the annotation used by kubectl apply + if err := util.CreateApplyAnnotation(info.Object, unstructured.UnstructuredJSONScheme); err != nil { + return cmdutil.AddSourceToErr("creating", info.Source, err) + } + + if !o.DryRun { + // Then create the resource and skip the three-way merge + options := metav1.CreateOptions{} + if o.ServerDryRun { + options.DryRun = []string{metav1.DryRunAll} + } + obj, err := resource.NewHelper(info.Client, info.Mapping).Create(info.Namespace, true, info.Object, &options) + if err != nil { + return cmdutil.AddSourceToErr("creating", info.Source, err) + } + info.Refresh(obj, true) + } + + metadata, err := meta.Accessor(info.Object) + if err != nil { + return err + } + visitedUids.Insert(string(metadata.GetUID())) + + count++ + + if printObject { + objs = append(objs, info.Object) + return nil + } + + printer, err := o.ToPrinter("created") + if err != nil { + return err + } + return printer.PrintObj(info.Object, o.Out) + } + + metadata, err := meta.Accessor(info.Object) + if err != nil { + return err + } + visitedUids.Insert(string(metadata.GetUID())) + + if !o.DryRun { + annotationMap := metadata.GetAnnotations() + if _, ok := annotationMap[corev1.LastAppliedConfigAnnotation]; !ok { + fmt.Fprintf(o.ErrOut, warningNoLastAppliedConfigAnnotation, o.cmdBaseName) + } + + helper := resource.NewHelper(info.Client, info.Mapping) + patcher := &Patcher{ + Mapping: info.Mapping, + Helper: helper, + DynamicClient: o.DynamicClient, + Overwrite: o.Overwrite, + BackOff: clockwork.NewRealClock(), + Force: o.DeleteOptions.ForceDeletion, + Cascade: o.DeleteOptions.Cascade, + Timeout: o.DeleteOptions.Timeout, + GracePeriod: o.DeleteOptions.GracePeriod, + ServerDryRun: o.ServerDryRun, + OpenapiSchema: openapiSchema, + Retries: maxPatchRetry, + } + + patchBytes, patchedObject, err := patcher.Patch(info.Object, modified, info.Source, info.Namespace, info.Name, o.ErrOut) + if err != nil { + return cmdutil.AddSourceToErr(fmt.Sprintf("applying patch:\n%s\nto:\n%v\nfor:", patchBytes, info), info.Source, err) + } + + info.Refresh(patchedObject, true) + + if string(patchBytes) == "{}" && !printObject { + count++ + + printer, err := o.ToPrinter("unchanged") + if err != nil { + return err + } + return printer.PrintObj(info.Object, o.Out) + } + } + count++ + + if printObject { + objs = append(objs, info.Object) + return nil + } + + printer, err := o.ToPrinter("configured") + if err != nil { + return err + } + return printer.PrintObj(info.Object, o.Out) + }) + if err != nil { + return err + } + + if count == 0 { + return fmt.Errorf("no objects passed to apply") + } + + // print objects + if len(objs) > 0 { + printer, err := o.ToPrinter("") + if err != nil { + return err + } + + objToPrint := objs[0] + if len(objs) > 1 { + list := &corev1.List{ + TypeMeta: metav1.TypeMeta{ + Kind: "List", + APIVersion: "v1", + }, + ListMeta: metav1.ListMeta{}, + } + if err := meta.SetList(list, objs); err != nil { + return err + } + + objToPrint = list + } + if err := printer.PrintObj(objToPrint, o.Out); err != nil { + return err + } + } + + if !o.Prune { + return nil + } + + p := pruner{ + mapper: o.Mapper, + dynamicClient: o.DynamicClient, + + labelSelector: o.Selector, + visitedUids: visitedUids, + + cascade: o.DeleteOptions.Cascade, + dryRun: o.DryRun, + serverDryRun: o.ServerDryRun, + gracePeriod: o.DeleteOptions.GracePeriod, + + toPrinter: o.ToPrinter, + + out: o.Out, + } + + namespacedRESTMappings, nonNamespacedRESTMappings, err := getRESTMappings(o.Mapper, &(o.PruneResources)) + if err != nil { + return fmt.Errorf("error retrieving RESTMappings to prune: %v", err) + } + + for n := range visitedNamespaces { + for _, m := range namespacedRESTMappings { + if err := p.prune(n, m); err != nil { + return fmt.Errorf("error pruning namespaced object %v: %v", m.GroupVersionKind, err) + } + } + } + for _, m := range nonNamespacedRESTMappings { + if err := p.prune(metav1.NamespaceNone, m); err != nil { + return fmt.Errorf("error pruning nonNamespaced object %v: %v", m.GroupVersionKind, err) + } + } + + return nil +} + +type pruneResource struct { + group string + version string + kind string + namespaced bool +} + +func (pr pruneResource) String() string { + return fmt.Sprintf("%v/%v, Kind=%v, Namespaced=%v", pr.group, pr.version, pr.kind, pr.namespaced) +} + +func getRESTMappings(mapper meta.RESTMapper, pruneResources *[]pruneResource) (namespaced, nonNamespaced []*meta.RESTMapping, err error) { + if len(*pruneResources) == 0 { + // default whitelist + // TODO: need to handle the older api versions - e.g. v1beta1 jobs. Github issue: #35991 + *pruneResources = []pruneResource{ + {"", "v1", "ConfigMap", true}, + {"", "v1", "Endpoints", true}, + {"", "v1", "Namespace", false}, + {"", "v1", "PersistentVolumeClaim", true}, + {"", "v1", "PersistentVolume", false}, + {"", "v1", "Pod", true}, + {"", "v1", "ReplicationController", true}, + {"", "v1", "Secret", true}, + {"", "v1", "Service", true}, + {"batch", "v1", "Job", true}, + {"batch", "v1beta1", "CronJob", true}, + {"extensions", "v1beta1", "Ingress", true}, + {"apps", "v1", "DaemonSet", true}, + {"apps", "v1", "Deployment", true}, + {"apps", "v1", "ReplicaSet", true}, + {"apps", "v1", "StatefulSet", true}, + } + } + + for _, resource := range *pruneResources { + addedMapping, err := mapper.RESTMapping(schema.GroupKind{Group: resource.group, Kind: resource.kind}, resource.version) + if err != nil { + return nil, nil, fmt.Errorf("invalid resource %v: %v", resource, err) + } + if resource.namespaced { + namespaced = append(namespaced, addedMapping) + } else { + nonNamespaced = append(nonNamespaced, addedMapping) + } + } + + return namespaced, nonNamespaced, nil +} + +type pruner struct { + mapper meta.RESTMapper + dynamicClient dynamic.Interface + + visitedUids sets.String + labelSelector string + fieldSelector string + + cascade bool + serverDryRun bool + dryRun bool + gracePeriod int + + toPrinter func(string) (printers.ResourcePrinter, error) + + out io.Writer +} + +func (p *pruner) prune(namespace string, mapping *meta.RESTMapping) error { + objList, err := p.dynamicClient.Resource(mapping.Resource). + Namespace(namespace). + List(metav1.ListOptions{ + LabelSelector: p.labelSelector, + FieldSelector: p.fieldSelector, + }) + if err != nil { + return err + } + + objs, err := meta.ExtractList(objList) + if err != nil { + return err + } + + for _, obj := range objs { + metadata, err := meta.Accessor(obj) + if err != nil { + return err + } + annots := metadata.GetAnnotations() + if _, ok := annots[corev1.LastAppliedConfigAnnotation]; !ok { + // don't prune resources not created with apply + continue + } + uid := metadata.GetUID() + if p.visitedUids.Has(string(uid)) { + continue + } + name := metadata.GetName() + if !p.dryRun { + if err := p.delete(namespace, name, mapping); err != nil { + return err + } + } + + printer, err := p.toPrinter("pruned") + if err != nil { + return err + } + printer.PrintObj(obj, p.out) + } + return nil +} + +func (p *pruner) delete(namespace, name string, mapping *meta.RESTMapping) error { + return runDelete(namespace, name, mapping, p.dynamicClient, p.cascade, p.gracePeriod, p.serverDryRun) +} + +func runDelete(namespace, name string, mapping *meta.RESTMapping, c dynamic.Interface, cascade bool, gracePeriod int, serverDryRun bool) error { + options := &metav1.DeleteOptions{} + if gracePeriod >= 0 { + options = metav1.NewDeleteOptions(int64(gracePeriod)) + } + if serverDryRun { + options.DryRun = []string{metav1.DryRunAll} + } + policy := metav1.DeletePropagationForeground + if !cascade { + policy = metav1.DeletePropagationOrphan + } + options.PropagationPolicy = &policy + return c.Resource(mapping.Resource).Namespace(namespace).Delete(name, options) +} + +func (p *Patcher) delete(namespace, name string) error { + return runDelete(namespace, name, p.Mapping, p.DynamicClient, p.Cascade, p.GracePeriod, p.ServerDryRun) +} + +// Patcher defines options to patch OpenAPI objects. +type Patcher struct { + Mapping *meta.RESTMapping + Helper *resource.Helper + DynamicClient dynamic.Interface + + Overwrite bool + BackOff clockwork.Clock + + Force bool + Cascade bool + Timeout time.Duration + GracePeriod int + ServerDryRun bool + + // If set, forces the patch against a specific resourceVersion + ResourceVersion *string + + // Number of retries to make if the patch fails with conflict + Retries int + + OpenapiSchema openapi.Resources +} + +// DryRunVerifier verifies if a given group-version-kind supports DryRun +// against the current server. Sending dryRun requests to apiserver that +// don't support it will result in objects being unwillingly persisted. +// +// It reads the OpenAPI to see if the given GVK supports dryRun. If the +// GVK can not be found, we assume that CRDs will have the same level of +// support as "namespaces", and non-CRDs will not be supported. We +// delay the check for CRDs as much as possible though, since it +// requires an extra round-trip to the server. +type DryRunVerifier struct { + Finder cmdutil.CRDFinder + OpenAPIGetter discovery.OpenAPISchemaInterface +} + +// HasSupport verifies if the given gvk supports DryRun. An error is +// returned if it doesn't. +func (v *DryRunVerifier) HasSupport(gvk schema.GroupVersionKind) error { + oapi, err := v.OpenAPIGetter.OpenAPISchema() + if err != nil { + return fmt.Errorf("failed to download openapi: %v", err) + } + supports, err := openapi.SupportsDryRun(oapi, gvk) + if err != nil { + // We assume that we couldn't find the type, then check for namespace: + supports, _ = openapi.SupportsDryRun(oapi, schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Namespace"}) + // If namespace supports dryRun, then we will support dryRun for CRDs only. + if supports { + supports, err = v.Finder.HasCRD(gvk.GroupKind()) + if err != nil { + return fmt.Errorf("failed to check CRD: %v", err) + } + } + } + if !supports { + return fmt.Errorf("%v doesn't support dry-run", gvk) + } + return nil +} + +func addResourceVersion(patch []byte, rv string) ([]byte, error) { + var patchMap map[string]interface{} + err := json.Unmarshal(patch, &patchMap) + if err != nil { + return nil, err + } + u := unstructured.Unstructured{Object: patchMap} + a, err := meta.Accessor(&u) + if err != nil { + return nil, err + } + a.SetResourceVersion(rv) + + return json.Marshal(patchMap) +} + +func (p *Patcher) patchSimple(obj runtime.Object, modified []byte, source, namespace, name string, errOut io.Writer) ([]byte, runtime.Object, error) { + // Serialize the current configuration of the object from the server. + current, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj) + if err != nil { + return nil, nil, cmdutil.AddSourceToErr(fmt.Sprintf("serializing current configuration from:\n%v\nfor:", obj), source, err) + } + + // Retrieve the original configuration of the object from the annotation. + original, err := util.GetOriginalConfiguration(obj) + if err != nil { + return nil, nil, cmdutil.AddSourceToErr(fmt.Sprintf("retrieving original configuration from:\n%v\nfor:", obj), source, err) + } + + var patchType types.PatchType + var patch []byte + var lookupPatchMeta strategicpatch.LookupPatchMeta + var schema oapi.Schema + createPatchErrFormat := "creating patch with:\noriginal:\n%s\nmodified:\n%s\ncurrent:\n%s\nfor:" + + // Create the versioned struct from the type defined in the restmapping + // (which is the API version we'll be submitting the patch to) + versionedObject, err := scheme.Scheme.New(p.Mapping.GroupVersionKind) + switch { + case runtime.IsNotRegisteredError(err): + // fall back to generic JSON merge patch + patchType = types.MergePatchType + preconditions := []mergepatch.PreconditionFunc{mergepatch.RequireKeyUnchanged("apiVersion"), + mergepatch.RequireKeyUnchanged("kind"), mergepatch.RequireMetadataKeyUnchanged("name")} + patch, err = jsonmergepatch.CreateThreeWayJSONMergePatch(original, modified, current, preconditions...) + if err != nil { + if mergepatch.IsPreconditionFailed(err) { + return nil, nil, fmt.Errorf("%s", "At least one of apiVersion, kind and name was changed") + } + return nil, nil, cmdutil.AddSourceToErr(fmt.Sprintf(createPatchErrFormat, original, modified, current), source, err) + } + case err != nil: + return nil, nil, cmdutil.AddSourceToErr(fmt.Sprintf("getting instance of versioned object for %v:", p.Mapping.GroupVersionKind), source, err) + case err == nil: + // Compute a three way strategic merge patch to send to server. + patchType = types.StrategicMergePatchType + + // Try to use openapi first if the openapi spec is available and can successfully calculate the patch. + // Otherwise, fall back to baked-in types. + if p.OpenapiSchema != nil { + if schema = p.OpenapiSchema.LookupResource(p.Mapping.GroupVersionKind); schema != nil { + lookupPatchMeta = strategicpatch.PatchMetaFromOpenAPI{Schema: schema} + if openapiPatch, err := strategicpatch.CreateThreeWayMergePatch(original, modified, current, lookupPatchMeta, p.Overwrite); err != nil { + fmt.Fprintf(errOut, "warning: error calculating patch from openapi spec: %v\n", err) + } else { + patchType = types.StrategicMergePatchType + patch = openapiPatch + } + } + } + + if patch == nil { + lookupPatchMeta, err = strategicpatch.NewPatchMetaFromStruct(versionedObject) + if err != nil { + return nil, nil, cmdutil.AddSourceToErr(fmt.Sprintf(createPatchErrFormat, original, modified, current), source, err) + } + patch, err = strategicpatch.CreateThreeWayMergePatch(original, modified, current, lookupPatchMeta, p.Overwrite) + if err != nil { + return nil, nil, cmdutil.AddSourceToErr(fmt.Sprintf(createPatchErrFormat, original, modified, current), source, err) + } + } + } + + if string(patch) == "{}" { + return patch, obj, nil + } + + if p.ResourceVersion != nil { + patch, err = addResourceVersion(patch, *p.ResourceVersion) + if err != nil { + return nil, nil, cmdutil.AddSourceToErr("Failed to insert resourceVersion in patch", source, err) + } + } + + options := metav1.PatchOptions{} + if p.ServerDryRun { + options.DryRun = []string{metav1.DryRunAll} + } + + patchedObj, err := p.Helper.Patch(namespace, name, patchType, patch, &options) + return patch, patchedObj, err +} + +// Patch tries to patch an OpenAPI resource. On success, returns the merge patch as well +// the final patched object. On failure, returns an error. +func (p *Patcher) Patch(current runtime.Object, modified []byte, source, namespace, name string, errOut io.Writer) ([]byte, runtime.Object, error) { + var getErr error + patchBytes, patchObject, err := p.patchSimple(current, modified, source, namespace, name, errOut) + if p.Retries == 0 { + p.Retries = maxPatchRetry + } + for i := 1; i <= p.Retries && errors.IsConflict(err); i++ { + if i > triesBeforeBackOff { + p.BackOff.Sleep(backOffPeriod) + } + current, getErr = p.Helper.Get(namespace, name, false) + if getErr != nil { + return nil, nil, getErr + } + patchBytes, patchObject, err = p.patchSimple(current, modified, source, namespace, name, errOut) + } + if err != nil && (errors.IsConflict(err) || errors.IsInvalid(err)) && p.Force { + patchBytes, patchObject, err = p.deleteAndCreate(current, modified, namespace, name) + } + return patchBytes, patchObject, err +} + +func (p *Patcher) deleteAndCreate(original runtime.Object, modified []byte, namespace, name string) ([]byte, runtime.Object, error) { + if err := p.delete(namespace, name); err != nil { + return modified, nil, err + } + // TODO: use wait + if err := wait.PollImmediate(1*time.Second, p.Timeout, func() (bool, error) { + if _, err := p.Helper.Get(namespace, name, false); !errors.IsNotFound(err) { + return false, err + } + return true, nil + }); err != nil { + return modified, nil, err + } + versionedObject, _, err := unstructured.UnstructuredJSONScheme.Decode(modified, nil, nil) + if err != nil { + return modified, nil, err + } + options := metav1.CreateOptions{} + if p.ServerDryRun { + options.DryRun = []string{metav1.DryRunAll} + } + createdObject, err := p.Helper.Create(namespace, true, versionedObject, &options) + if err != nil { + // restore the original object if we fail to create the new one + // but still propagate and advertise error to user + recreated, recreateErr := p.Helper.Create(namespace, true, original, &options) + if recreateErr != nil { + err = fmt.Errorf("An error occurred force-replacing the existing object with the newly provided one:\n\n%v.\n\nAdditionally, an error occurred attempting to restore the original object:\n\n%v", err, recreateErr) + } else { + createdObject = recreated + } + } + return modified, createdObject, err +} diff --git a/pkg/cmd/apply/apply_edit_last_applied.go b/pkg/cmd/apply/apply_edit_last_applied.go new file mode 100644 index 000000000..e005b5557 --- /dev/null +++ b/pkg/cmd/apply/apply_edit_last_applied.go @@ -0,0 +1,89 @@ +/* +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 apply + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/cmd/util/editor" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + applyEditLastAppliedLong = templates.LongDesc(` + Edit the latest last-applied-configuration annotations of resources from the default editor. + + The edit-last-applied command allows you to directly edit any API resource you can retrieve via the + command line tools. It will open the editor defined by your KUBE_EDITOR, or EDITOR + environment variables, or fall back to 'vi' for Linux or 'notepad' for Windows. + You can edit multiple objects, although changes are applied one at a time. The command + accepts filenames as well as command line arguments, although the files you point to must + be previously saved versions of resources. + + The default format is YAML. To edit in JSON, specify "-o json". + + The flag --windows-line-endings can be used to force Windows line endings, + otherwise the default for your operating system will be used. + + In the event an error occurs while updating, a temporary file will be created on disk + that contains your unapplied changes. The most common error when updating a resource + is another editor changing the resource on the server. When this occurs, you will have + to apply your changes to the newer version of the resource, or update your temporary + saved copy to include the latest resource version.`) + + applyEditLastAppliedExample = templates.Examples(` + # Edit the last-applied-configuration annotations by type/name in YAML. + kubectl apply edit-last-applied deployment/nginx + + # Edit the last-applied-configuration annotations by file in JSON. + kubectl apply edit-last-applied -f deploy.yaml -o json`) +) + +// NewCmdApplyEditLastApplied created the cobra CLI command for the `apply edit-last-applied` command. +func NewCmdApplyEditLastApplied(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := editor.NewEditOptions(editor.ApplyEditMode, ioStreams) + + cmd := &cobra.Command{ + Use: "edit-last-applied (RESOURCE/NAME | -f FILENAME)", + DisableFlagsInUseLine: true, + Short: "Edit latest last-applied-configuration annotations of a resource/object", + Long: applyEditLastAppliedLong, + Example: applyEditLastAppliedExample, + Run: func(cmd *cobra.Command, args []string) { + if err := o.Complete(f, args, cmd); err != nil { + cmdutil.CheckErr(err) + } + if err := o.Run(); err != nil { + cmdutil.CheckErr(err) + } + }, + } + + // bind flag structs + o.RecordFlags.AddFlags(cmd) + o.PrintFlags.AddFlags(cmd) + + usage := "to use to edit the resource" + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + cmd.Flags().BoolVar(&o.WindowsLineEndings, "windows-line-endings", o.WindowsLineEndings, + "Defaults to the line ending native to your platform.") + cmdutil.AddIncludeUninitializedFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/apply/apply_set_last_applied.go b/pkg/cmd/apply/apply_set_last_applied.go new file mode 100644 index 000000000..fd220b4c0 --- /dev/null +++ b/pkg/cmd/apply/apply_set_last_applied.go @@ -0,0 +1,219 @@ +/* +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 apply + +import ( + "bytes" + "fmt" + + "github.com/spf13/cobra" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/cmd/util/editor" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// SetLastAppliedOptions defines options for the `apply set-last-applied` command.` +type SetLastAppliedOptions struct { + CreateAnnotation bool + + PrintFlags *genericclioptions.PrintFlags + PrintObj printers.ResourcePrinterFunc + + FilenameOptions resource.FilenameOptions + + infoList []*resource.Info + namespace string + enforceNamespace bool + dryRun bool + shortOutput bool + output string + patchBufferList []PatchBuffer + builder *resource.Builder + unstructuredClientForMapping func(mapping *meta.RESTMapping) (resource.RESTClient, error) + + genericclioptions.IOStreams +} + +// PatchBuffer caches changes that are to be applied. +type PatchBuffer struct { + Patch []byte + PatchType types.PatchType +} + +var ( + applySetLastAppliedLong = templates.LongDesc(i18n.T(` + Set the latest last-applied-configuration annotations by setting it to match the contents of a file. + This results in the last-applied-configuration being updated as though 'kubectl apply -f ' was run, + without updating any other parts of the object.`)) + + applySetLastAppliedExample = templates.Examples(i18n.T(` + # Set the last-applied-configuration of a resource to match the contents of a file. + kubectl apply set-last-applied -f deploy.yaml + + # Execute set-last-applied against each configuration file in a directory. + kubectl apply set-last-applied -f path/ + + # Set the last-applied-configuration of a resource to match the contents of a file, will create the annotation if it does not already exist. + kubectl apply set-last-applied -f deploy.yaml --create-annotation=true + `)) +) + +// NewSetLastAppliedOptions takes option arguments from a CLI stream and returns it at SetLastAppliedOptions type. +func NewSetLastAppliedOptions(ioStreams genericclioptions.IOStreams) *SetLastAppliedOptions { + return &SetLastAppliedOptions{ + PrintFlags: genericclioptions.NewPrintFlags("configured").WithTypeSetter(scheme.Scheme), + IOStreams: ioStreams, + } +} + +// NewCmdApplySetLastApplied creates the cobra CLI `apply` subcommand `set-last-applied`.` +func NewCmdApplySetLastApplied(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewSetLastAppliedOptions(ioStreams) + cmd := &cobra.Command{ + Use: "set-last-applied -f FILENAME", + DisableFlagsInUseLine: true, + Short: i18n.T("Set the last-applied-configuration annotation on a live object to match the contents of a file."), + Long: applySetLastAppliedLong, + Example: applySetLastAppliedExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunSetLastApplied()) + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmdutil.AddDryRunFlag(cmd) + cmd.Flags().BoolVar(&o.CreateAnnotation, "create-annotation", o.CreateAnnotation, "Will create 'last-applied-configuration' annotations if current objects doesn't have one") + cmdutil.AddJsonFilenameFlag(cmd.Flags(), &o.FilenameOptions.Filenames, "Filename, directory, or URL to files that contains the last-applied-configuration annotations") + + return cmd +} + +// Complete populates dry-run and output flag options. +func (o *SetLastAppliedOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + o.dryRun = cmdutil.GetDryRunFlag(cmd) + o.output = cmdutil.GetFlagString(cmd, "output") + o.shortOutput = o.output == "name" + + var err error + o.namespace, o.enforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + o.builder = f.NewBuilder() + o.unstructuredClientForMapping = f.UnstructuredClientForMapping + + if o.dryRun { + // TODO(juanvallejo): This can be cleaned up even further by creating + // a PrintFlags struct that binds the --dry-run flag, and whose + // ToPrinter method returns a printer that understands how to print + // this success message. + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = printer.PrintObj + + return nil +} + +// Validate checks SetLastAppliedOptions for validity. +func (o *SetLastAppliedOptions) Validate() error { + r := o.builder. + Unstructured(). + NamespaceParam(o.namespace).DefaultNamespace(). + FilenameParam(o.enforceNamespace, &o.FilenameOptions). + Flatten(). + Do() + + err := r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + patchBuf, diffBuf, patchType, err := editor.GetApplyPatch(info.Object.(runtime.Unstructured)) + if err != nil { + return err + } + + // Verify the object exists in the cluster before trying to patch it. + if err := info.Get(); err != nil { + if errors.IsNotFound(err) { + return err + } + return cmdutil.AddSourceToErr(fmt.Sprintf("retrieving current configuration of:\n%s\nfrom server for:", info.String()), info.Source, err) + } + originalBuf, err := util.GetOriginalConfiguration(info.Object) + if err != nil { + return cmdutil.AddSourceToErr(fmt.Sprintf("retrieving current configuration of:\n%s\nfrom server for:", info.String()), info.Source, err) + } + if originalBuf == nil && !o.CreateAnnotation { + return fmt.Errorf("no last-applied-configuration annotation found on resource: %s, to create the annotation, run the command with --create-annotation", info.Name) + } + + //only add to PatchBufferList when changed + if !bytes.Equal(cmdutil.StripComments(originalBuf), cmdutil.StripComments(diffBuf)) { + p := PatchBuffer{Patch: patchBuf, PatchType: patchType} + o.patchBufferList = append(o.patchBufferList, p) + o.infoList = append(o.infoList, info) + } else { + fmt.Fprintf(o.Out, "set-last-applied %s: no changes required.\n", info.Name) + } + + return nil + }) + return err +} + +// RunSetLastApplied executes the `set-last-applied` command according to SetLastAppliedOptions. +func (o *SetLastAppliedOptions) RunSetLastApplied() error { + for i, patch := range o.patchBufferList { + info := o.infoList[i] + finalObj := info.Object + + if !o.dryRun { + mapping := info.ResourceMapping() + client, err := o.unstructuredClientForMapping(mapping) + if err != nil { + return err + } + helper := resource.NewHelper(client, mapping) + finalObj, err = helper.Patch(o.namespace, info.Name, patch.PatchType, patch.Patch, nil) + if err != nil { + return err + } + } + if err := o.PrintObj(finalObj, o.Out); err != nil { + return err + } + } + return nil +} diff --git a/pkg/cmd/apply/apply_test.go b/pkg/cmd/apply/apply_test.go new file mode 100644 index 000000000..2c04db446 --- /dev/null +++ b/pkg/cmd/apply/apply_test.go @@ -0,0 +1,1414 @@ +/* +Copyright 2014 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 apply + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/googleapis/gnostic/OpenAPIv2" + "github.com/spf13/cobra" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + kubeerr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + sptest "k8s.io/apimachinery/pkg/util/strategicpatch/testing" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + dynamicfakeclient "k8s.io/client-go/dynamic/fake" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + clienttesting "k8s.io/client-go/testing" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/openapi" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" + utilpointer "k8s.io/utils/pointer" +) + +var ( + fakeSchema = sptest.Fake{Path: filepath.Join("..", "..", "..", "..", "api", "openapi-spec", "swagger.json")} + testingOpenAPISchemaFns = []func() (openapi.Resources, error){nil, AlwaysErrorOpenAPISchemaFn, openAPISchemaFn} + AlwaysErrorOpenAPISchemaFn = func() (openapi.Resources, error) { + return nil, errors.New("cannot get openapi spec") + } + openAPISchemaFn = func() (openapi.Resources, error) { + s, err := fakeSchema.OpenAPISchema() + if err != nil { + return nil, err + } + return openapi.NewOpenAPIData(s) + } + codec = scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) +) + +func TestApplyExtraArgsFail(t *testing.T) { + f := cmdtesting.NewTestFactory() + defer f.Cleanup() + + c := NewCmdApply("kubectl", f, genericclioptions.NewTestIOStreamsDiscard()) + if validateApplyArgs(c, []string{"rc"}) == nil { + t.Fatalf("unexpected non-error") + } +} + +func validateApplyArgs(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args) + } + return nil +} + +const ( + filenameCM = "../../../../test/fixtures/pkg/kubectl/cmd/apply/cm.yaml" + filenameRC = "../../../../test/fixtures/pkg/kubectl/cmd/apply/rc.yaml" + filenameRCArgs = "../../../../test/fixtures/pkg/kubectl/cmd/apply/rc-args.yaml" + filenameRCLastAppliedArgs = "../../../../test/fixtures/pkg/kubectl/cmd/apply/rc-lastapplied-args.yaml" + filenameRCNoAnnotation = "../../../../test/fixtures/pkg/kubectl/cmd/apply/rc-no-annotation.yaml" + filenameRCLASTAPPLIED = "../../../../test/fixtures/pkg/kubectl/cmd/apply/rc-lastapplied.yaml" + filenameSVC = "../../../../test/fixtures/pkg/kubectl/cmd/apply/service.yaml" + filenameRCSVC = "../../../../test/fixtures/pkg/kubectl/cmd/apply/rc-service.yaml" + filenameNoExistRC = "../../../../test/fixtures/pkg/kubectl/cmd/apply/rc-noexist.yaml" + filenameRCPatchTest = "../../../../test/fixtures/pkg/kubectl/cmd/apply/patch.json" + dirName = "../../../../test/fixtures/pkg/kubectl/cmd/apply/testdir" + filenameRCJSON = "../../../../test/fixtures/pkg/kubectl/cmd/apply/rc.json" + + filenameWidgetClientside = "../../../../test/fixtures/pkg/kubectl/cmd/apply/widget-clientside.yaml" + filenameWidgetServerside = "../../../../test/fixtures/pkg/kubectl/cmd/apply/widget-serverside.yaml" + filenameDeployObjServerside = "../../../../test/fixtures/pkg/kubectl/cmd/apply/deploy-serverside.yaml" + filenameDeployObjClientside = "../../../../test/fixtures/pkg/kubectl/cmd/apply/deploy-clientside.yaml" +) + +func readConfigMapList(t *testing.T, filename string) [][]byte { + data := readBytesFromFile(t, filename) + cmList := corev1.ConfigMapList{} + if err := runtime.DecodeInto(codec, data, &cmList); err != nil { + t.Fatal(err) + } + + var listCmBytes [][]byte + + for _, cm := range cmList.Items { + cmBytes, err := runtime.Encode(codec, &cm) + if err != nil { + t.Fatal(err) + } + listCmBytes = append(listCmBytes, cmBytes) + } + + return listCmBytes +} + +func readBytesFromFile(t *testing.T, filename string) []byte { + file, err := os.Open(filename) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + data, err := ioutil.ReadAll(file) + if err != nil { + t.Fatal(err) + } + + return data +} + +func readReplicationController(t *testing.T, filenameRC string) (string, []byte) { + rcObj := readReplicationControllerFromFile(t, filenameRC) + metaAccessor, err := meta.Accessor(rcObj) + if err != nil { + t.Fatal(err) + } + rcBytes, err := runtime.Encode(codec, rcObj) + if err != nil { + t.Fatal(err) + } + + return metaAccessor.GetName(), rcBytes +} + +func readReplicationControllerFromFile(t *testing.T, filename string) *corev1.ReplicationController { + data := readBytesFromFile(t, filename) + rc := corev1.ReplicationController{} + if err := runtime.DecodeInto(codec, data, &rc); err != nil { + t.Fatal(err) + } + + return &rc +} + +func readUnstructuredFromFile(t *testing.T, filename string) *unstructured.Unstructured { + data := readBytesFromFile(t, filename) + unst := unstructured.Unstructured{} + if err := runtime.DecodeInto(codec, data, &unst); err != nil { + t.Fatal(err) + } + return &unst +} + +func readServiceFromFile(t *testing.T, filename string) *corev1.Service { + data := readBytesFromFile(t, filename) + svc := corev1.Service{} + if err := runtime.DecodeInto(codec, data, &svc); err != nil { + t.Fatal(err) + } + + return &svc +} + +func annotateRuntimeObject(t *testing.T, originalObj, currentObj runtime.Object, kind string) (string, []byte) { + originalAccessor, err := meta.Accessor(originalObj) + if err != nil { + t.Fatal(err) + } + + // The return value of this function is used in the body of the GET + // request in the unit tests. Here we are adding a misc label to the object. + // In tests, the validatePatchApplication() gets called in PATCH request + // handler in fake round tripper. validatePatchApplication call + // checks that this DELETE_ME label was deleted by the apply implementation in + // kubectl. + originalLabels := originalAccessor.GetLabels() + originalLabels["DELETE_ME"] = "DELETE_ME" + originalAccessor.SetLabels(originalLabels) + original, err := runtime.Encode(unstructured.JSONFallbackEncoder{Encoder: codec}, originalObj) + if err != nil { + t.Fatal(err) + } + + currentAccessor, err := meta.Accessor(currentObj) + if err != nil { + t.Fatal(err) + } + + currentAnnotations := currentAccessor.GetAnnotations() + if currentAnnotations == nil { + currentAnnotations = make(map[string]string) + } + currentAnnotations[corev1.LastAppliedConfigAnnotation] = string(original) + currentAccessor.SetAnnotations(currentAnnotations) + current, err := runtime.Encode(unstructured.JSONFallbackEncoder{Encoder: codec}, currentObj) + if err != nil { + t.Fatal(err) + } + + return currentAccessor.GetName(), current +} + +func readAndAnnotateReplicationController(t *testing.T, filename string) (string, []byte) { + rc1 := readReplicationControllerFromFile(t, filename) + rc2 := readReplicationControllerFromFile(t, filename) + return annotateRuntimeObject(t, rc1, rc2, "ReplicationController") +} + +func readAndAnnotateService(t *testing.T, filename string) (string, []byte) { + svc1 := readServiceFromFile(t, filename) + svc2 := readServiceFromFile(t, filename) + return annotateRuntimeObject(t, svc1, svc2, "Service") +} + +func readAndAnnotateUnstructured(t *testing.T, filename string) (string, []byte) { + obj1 := readUnstructuredFromFile(t, filename) + obj2 := readUnstructuredFromFile(t, filename) + return annotateRuntimeObject(t, obj1, obj2, "Widget") +} + +func validatePatchApplication(t *testing.T, req *http.Request) { + patch, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + + patchMap := map[string]interface{}{} + if err := json.Unmarshal(patch, &patchMap); err != nil { + t.Fatal(err) + } + + annotationsMap := walkMapPath(t, patchMap, []string{"metadata", "annotations"}) + if _, ok := annotationsMap[corev1.LastAppliedConfigAnnotation]; !ok { + t.Fatalf("patch does not contain annotation:\n%s\n", patch) + } + + labelMap := walkMapPath(t, patchMap, []string{"metadata", "labels"}) + if deleteMe, ok := labelMap["DELETE_ME"]; !ok || deleteMe != nil { + t.Fatalf("patch does not remove deleted key: DELETE_ME:\n%s\n", patch) + } +} + +func walkMapPath(t *testing.T, start map[string]interface{}, path []string) map[string]interface{} { + finish := start + for i := 0; i < len(path); i++ { + var ok bool + finish, ok = finish[path[i]].(map[string]interface{}) + if !ok { + t.Fatalf("key:%s of path:%v not found in map:%v", path[i], path, start) + } + } + + return finish +} + +func TestRunApplyPrintsValidObjectList(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + configMapList := readConfigMapList(t, filenameCM) + pathCM := "/namespaces/test/configmaps" + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case strings.HasPrefix(p, pathCM) && m == "GET": + fallthrough + case strings.HasPrefix(p, pathCM) && m == "PATCH": + var body io.ReadCloser + + switch p { + case pathCM + "/test0": + body = ioutil.NopCloser(bytes.NewReader(configMapList[0])) + case pathCM + "/test1": + body = ioutil.NopCloser(bytes.NewReader(configMapList[1])) + default: + t.Errorf("unexpected request to %s", p) + } + + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameCM) + cmd.Flags().Set("output", "json") + cmd.Flags().Set("dry-run", "true") + cmd.Run(cmd, []string{}) + + // ensure that returned list can be unmarshaled back into a configmap list + cmList := corev1.List{} + if err := runtime.DecodeInto(codec, buf.Bytes(), &cmList); err != nil { + t.Fatal(err) + } + + if len(cmList.Items) != 2 { + t.Fatalf("Expected 2 items in the result; got %d", len(cmList.Items)) + } + if !strings.Contains(string(cmList.Items[0].Raw), "key1") { + t.Fatalf("Did not get first ConfigMap at the first position") + } + if !strings.Contains(string(cmList.Items[1].Raw), "key2") { + t.Fatalf("Did not get second ConfigMap at the second position") + } +} + +func TestRunApplyViewLastApplied(t *testing.T) { + _, rcBytesWithConfig := readReplicationController(t, filenameRCLASTAPPLIED) + _, rcBytesWithArgs := readReplicationController(t, filenameRCLastAppliedArgs) + nameRC, rcBytes := readReplicationController(t, filenameRC) + pathRC := "/namespaces/test/replicationcontrollers/" + nameRC + + tests := []struct { + name, nameRC, pathRC, filePath, outputFormat, expectedErr, expectedOut, selector string + args []string + respBytes []byte + }{ + { + name: "view with file", + filePath: filenameRC, + outputFormat: "", + expectedErr: "", + expectedOut: "test: 1234\n", + selector: "", + args: []string{}, + respBytes: rcBytesWithConfig, + }, + { + name: "test with file include `%s` in arguments", + filePath: filenameRCArgs, + outputFormat: "", + expectedErr: "", + expectedOut: "args: -random_flag=%s@domain.com\n", + selector: "", + args: []string{}, + respBytes: rcBytesWithArgs, + }, + { + name: "view with file json format", + filePath: filenameRC, + outputFormat: "json", + expectedErr: "", + expectedOut: "{\n \"test\": 1234\n}\n", + selector: "", + args: []string{}, + respBytes: rcBytesWithConfig, + }, + { + name: "view resource/name invalid format", + filePath: "", + outputFormat: "wide", + expectedErr: "error: Unexpected -o output mode: wide, the flag 'output' must be one of yaml|json\nSee 'view-last-applied -h' for help and examples", + expectedOut: "", + selector: "", + args: []string{"replicationcontroller", "test-rc"}, + respBytes: rcBytesWithConfig, + }, + { + name: "view resource with label", + filePath: "", + outputFormat: "", + expectedErr: "", + expectedOut: "test: 1234\n", + selector: "name=test-rc", + args: []string{"replicationcontroller"}, + respBytes: rcBytesWithConfig, + }, + { + name: "view resource without annotations", + filePath: "", + outputFormat: "", + expectedErr: "error: no last-applied-configuration annotation found on resource: test-rc", + expectedOut: "", + selector: "", + args: []string{"replicationcontroller", "test-rc"}, + respBytes: rcBytes, + }, + { + name: "view resource no match", + filePath: "", + outputFormat: "", + expectedErr: "Error from server (NotFound): the server could not find the requested resource (get replicationcontrollers no-match)", + expectedOut: "", + selector: "", + args: []string{"replicationcontroller", "no-match"}, + respBytes: nil, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == pathRC && m == "GET": + bodyRC := ioutil.NopCloser(bytes.NewReader(test.respBytes)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == "/namespaces/test/replicationcontrollers" && m == "GET": + bodyRC := ioutil.NopCloser(bytes.NewReader(test.respBytes)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == "/namespaces/test/replicationcontrollers/no-match" && m == "GET": + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.Pod{})}, nil + case p == "/api/v1/namespaces/test" && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.Namespace{})}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + cmdutil.BehaviorOnFatal(func(str string, code int) { + if str != test.expectedErr { + t.Errorf("%s: unexpected error: %s\nexpected: %s", test.name, str, test.expectedErr) + } + }) + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdApplyViewLastApplied(tf, ioStreams) + if test.filePath != "" { + cmd.Flags().Set("filename", test.filePath) + } + if test.outputFormat != "" { + cmd.Flags().Set("output", test.outputFormat) + } + if test.selector != "" { + cmd.Flags().Set("selector", test.selector) + } + + cmd.Run(cmd, test.args) + if buf.String() != test.expectedOut { + t.Fatalf("%s: unexpected output: %s\nexpected: %s", test.name, buf.String(), test.expectedOut) + } + }) + } +} + +func TestApplyObjectWithoutAnnotation(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + nameRC, rcBytes := readReplicationController(t, filenameRC) + pathRC := "/namespaces/test/replicationcontrollers/" + nameRC + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == pathRC && m == "GET": + bodyRC := ioutil.NopCloser(bytes.NewReader(rcBytes)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == pathRC && m == "PATCH": + bodyRC := ioutil.NopCloser(bytes.NewReader(rcBytes)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameRC) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + // uses the name from the file, not the response + expectRC := "replicationcontroller/" + nameRC + "\n" + expectWarning := fmt.Sprintf(warningNoLastAppliedConfigAnnotation, "kubectl") + if errBuf.String() != expectWarning { + t.Fatalf("unexpected non-warning: %s\nexpected: %s", errBuf.String(), expectWarning) + } + if buf.String() != expectRC { + t.Fatalf("unexpected output: %s\nexpected: %s", buf.String(), expectRC) + } +} + +func TestApplyObject(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + nameRC, currentRC := readAndAnnotateReplicationController(t, filenameRC) + pathRC := "/namespaces/test/replicationcontrollers/" + nameRC + + for _, fn := range testingOpenAPISchemaFns { + t.Run("test apply when a local object is specified", func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == pathRC && m == "GET": + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == pathRC && m == "PATCH": + validatePatchApplication(t, req) + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.OpenAPISchemaFunc = fn + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameRC) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + // uses the name from the file, not the response + expectRC := "replicationcontroller/" + nameRC + "\n" + if buf.String() != expectRC { + t.Fatalf("unexpected output: %s\nexpected: %s", buf.String(), expectRC) + } + if errBuf.String() != "" { + t.Fatalf("unexpected error output: %s", errBuf.String()) + } + }) + } +} + +func TestApplyObjectOutput(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + nameRC, currentRC := readAndAnnotateReplicationController(t, filenameRC) + pathRC := "/namespaces/test/replicationcontrollers/" + nameRC + + // Add some extra data to the post-patch object + postPatchObj := &unstructured.Unstructured{} + if err := json.Unmarshal(currentRC, &postPatchObj.Object); err != nil { + t.Fatal(err) + } + postPatchLabels := postPatchObj.GetLabels() + if postPatchLabels == nil { + postPatchLabels = map[string]string{} + } + postPatchLabels["post-patch"] = "value" + postPatchObj.SetLabels(postPatchLabels) + postPatchData, err := json.Marshal(postPatchObj) + if err != nil { + t.Fatal(err) + } + + for _, fn := range testingOpenAPISchemaFns { + t.Run("test apply returns correct output", func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == pathRC && m == "GET": + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == pathRC && m == "PATCH": + validatePatchApplication(t, req) + bodyRC := ioutil.NopCloser(bytes.NewReader(postPatchData)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.OpenAPISchemaFunc = fn + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameRC) + cmd.Flags().Set("output", "yaml") + cmd.Run(cmd, []string{}) + + if !strings.Contains(buf.String(), "test-rc") { + t.Fatalf("unexpected output: %s\nexpected to contain: %s", buf.String(), "test-rc") + } + if !strings.Contains(buf.String(), "post-patch: value") { + t.Fatalf("unexpected output: %s\nexpected to contain: %s", buf.String(), "post-patch: value") + } + if errBuf.String() != "" { + t.Fatalf("unexpected error output: %s", errBuf.String()) + } + }) + } +} + +func TestApplyRetry(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + nameRC, currentRC := readAndAnnotateReplicationController(t, filenameRC) + pathRC := "/namespaces/test/replicationcontrollers/" + nameRC + + for _, fn := range testingOpenAPISchemaFns { + t.Run("test apply retries on conflict error", func(t *testing.T) { + firstPatch := true + retry := false + getCount := 0 + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == pathRC && m == "GET": + getCount++ + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == pathRC && m == "PATCH": + if firstPatch { + firstPatch = false + statusErr := kubeerr.NewConflict(schema.GroupResource{Group: "", Resource: "rc"}, "test-rc", fmt.Errorf("the object has been modified. Please apply at first")) + bodyBytes, _ := json.Marshal(statusErr) + bodyErr := ioutil.NopCloser(bytes.NewReader(bodyBytes)) + return &http.Response{StatusCode: http.StatusConflict, Header: cmdtesting.DefaultHeader(), Body: bodyErr}, nil + } + retry = true + validatePatchApplication(t, req) + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.OpenAPISchemaFunc = fn + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameRC) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if !retry || getCount != 2 { + t.Fatalf("apply didn't retry when get conflict error") + } + + // uses the name from the file, not the response + expectRC := "replicationcontroller/" + nameRC + "\n" + if buf.String() != expectRC { + t.Fatalf("unexpected output: %s\nexpected: %s", buf.String(), expectRC) + } + if errBuf.String() != "" { + t.Fatalf("unexpected error output: %s", errBuf.String()) + } + }) + } +} + +func TestApplyNonExistObject(t *testing.T) { + nameRC, currentRC := readAndAnnotateReplicationController(t, filenameRC) + pathRC := "/namespaces/test/replicationcontrollers" + pathNameRC := pathRC + "/" + nameRC + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api/v1/namespaces/test" && m == "GET": + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader(nil))}, nil + case p == pathNameRC && m == "GET": + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader(nil))}, nil + case p == pathRC && m == "POST": + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameRC) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + // uses the name from the file, not the response + expectRC := "replicationcontroller/" + nameRC + "\n" + if buf.String() != expectRC { + t.Errorf("unexpected output: %s\nexpected: %s", buf.String(), expectRC) + } +} + +func TestApplyEmptyPatch(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + nameRC, _ := readAndAnnotateReplicationController(t, filenameRC) + pathRC := "/namespaces/test/replicationcontrollers" + pathNameRC := pathRC + "/" + nameRC + + verifyPost := false + + var body []byte + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api/v1/namespaces/test" && m == "GET": + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader(nil))}, nil + case p == pathNameRC && m == "GET": + if body == nil { + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader(nil))}, nil + } + bodyRC := ioutil.NopCloser(bytes.NewReader(body)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == pathRC && m == "POST": + body, _ = ioutil.ReadAll(req.Body) + verifyPost = true + bodyRC := ioutil.NopCloser(bytes.NewReader(body)) + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + // 1. apply non exist object + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameRC) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + expectRC := "replicationcontroller/" + nameRC + "\n" + if buf.String() != expectRC { + t.Fatalf("unexpected output: %s\nexpected: %s", buf.String(), expectRC) + } + if !verifyPost { + t.Fatal("No server-side post call detected") + } + + // 2. test apply already exist object, will not send empty patch request + ioStreams, _, buf, _ = genericclioptions.NewTestIOStreams() + cmd = NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameRC) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if buf.String() != expectRC { + t.Fatalf("unexpected output: %s\nexpected: %s", buf.String(), expectRC) + } +} + +func TestApplyMultipleObjectsAsList(t *testing.T) { + testApplyMultipleObjects(t, true) +} + +func TestApplyMultipleObjectsAsFiles(t *testing.T) { + testApplyMultipleObjects(t, false) +} + +func testApplyMultipleObjects(t *testing.T, asList bool) { + nameRC, currentRC := readAndAnnotateReplicationController(t, filenameRC) + pathRC := "/namespaces/test/replicationcontrollers/" + nameRC + + nameSVC, currentSVC := readAndAnnotateService(t, filenameSVC) + pathSVC := "/namespaces/test/services/" + nameSVC + + for _, fn := range testingOpenAPISchemaFns { + t.Run("test apply on multiple objects", func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == pathRC && m == "GET": + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == pathRC && m == "PATCH": + validatePatchApplication(t, req) + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == pathSVC && m == "GET": + bodySVC := ioutil.NopCloser(bytes.NewReader(currentSVC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodySVC}, nil + case p == pathSVC && m == "PATCH": + validatePatchApplication(t, req) + bodySVC := ioutil.NopCloser(bytes.NewReader(currentSVC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodySVC}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.OpenAPISchemaFunc = fn + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + if asList { + cmd.Flags().Set("filename", filenameRCSVC) + } else { + cmd.Flags().Set("filename", filenameRC) + cmd.Flags().Set("filename", filenameSVC) + } + cmd.Flags().Set("output", "name") + + cmd.Run(cmd, []string{}) + + // Names should come from the REST response, NOT the files + expectRC := "replicationcontroller/" + nameRC + "\n" + expectSVC := "service/" + nameSVC + "\n" + // Test both possible orders since output is non-deterministic. + expectOne := expectRC + expectSVC + expectTwo := expectSVC + expectRC + if buf.String() != expectOne && buf.String() != expectTwo { + t.Fatalf("unexpected output: %s\nexpected: %s OR %s", buf.String(), expectOne, expectTwo) + } + if errBuf.String() != "" { + t.Fatalf("unexpected error output: %s", errBuf.String()) + } + }) + } +} + +func readDeploymentFromFile(t *testing.T, file string) []byte { + raw := readBytesFromFile(t, file) + obj := &appsv1.Deployment{} + if err := runtime.DecodeInto(codec, raw, obj); err != nil { + t.Fatal(err) + } + objJSON, err := runtime.Encode(codec, obj) + if err != nil { + t.Fatal(err) + } + return objJSON +} + +func TestApplyNULLPreservation(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + deploymentName := "nginx-deployment" + deploymentPath := "/namespaces/test/deployments/" + deploymentName + + verifiedPatch := false + deploymentBytes := readDeploymentFromFile(t, filenameDeployObjServerside) + + for _, fn := range testingOpenAPISchemaFns { + t.Run("test apply preserves NULL fields", func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == deploymentPath && m == "GET": + body := ioutil.NopCloser(bytes.NewReader(deploymentBytes)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + case p == deploymentPath && m == "PATCH": + patch, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + + patchMap := map[string]interface{}{} + if err := json.Unmarshal(patch, &patchMap); err != nil { + t.Fatal(err) + } + annotationMap := walkMapPath(t, patchMap, []string{"metadata", "annotations"}) + if _, ok := annotationMap[corev1.LastAppliedConfigAnnotation]; !ok { + t.Fatalf("patch does not contain annotation:\n%s\n", patch) + } + strategy := walkMapPath(t, patchMap, []string{"spec", "strategy"}) + if value, ok := strategy["rollingUpdate"]; !ok || value != nil { + t.Fatalf("patch did not retain null value in key: rollingUpdate:\n%s\n", patch) + } + verifiedPatch = true + + // The real API server would had returned the patched object but Kubectl + // is ignoring the actual return object. + // TODO: Make this match actual server behavior by returning the patched object. + body := ioutil.NopCloser(bytes.NewReader(deploymentBytes)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.OpenAPISchemaFunc = fn + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameDeployObjClientside) + cmd.Flags().Set("output", "name") + + cmd.Run(cmd, []string{}) + + expected := "deployment.apps/" + deploymentName + "\n" + if buf.String() != expected { + t.Fatalf("unexpected output: %s\nexpected: %s", buf.String(), expected) + } + if errBuf.String() != "" { + t.Fatalf("unexpected error output: %s", errBuf.String()) + } + if !verifiedPatch { + t.Fatal("No server-side patch call detected") + } + }) + } +} + +// TestUnstructuredApply checks apply operations on an unstructured object +func TestUnstructuredApply(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + name, curr := readAndAnnotateUnstructured(t, filenameWidgetClientside) + path := "/namespaces/test/widgets/" + name + + verifiedPatch := false + + for _, fn := range testingOpenAPISchemaFns { + t.Run("test apply works correctly with unstructured objects", func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == path && m == "GET": + body := ioutil.NopCloser(bytes.NewReader(curr)) + return &http.Response{ + StatusCode: 200, + Header: cmdtesting.DefaultHeader(), + Body: body}, nil + case p == path && m == "PATCH": + contentType := req.Header.Get("Content-Type") + if contentType != "application/merge-patch+json" { + t.Fatalf("Unexpected Content-Type: %s", contentType) + } + validatePatchApplication(t, req) + verifiedPatch = true + + body := ioutil.NopCloser(bytes.NewReader(curr)) + return &http.Response{ + StatusCode: 200, + Header: cmdtesting.DefaultHeader(), + Body: body}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.OpenAPISchemaFunc = fn + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameWidgetClientside) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + expected := "widget.unit-test.test.com/" + name + "\n" + if buf.String() != expected { + t.Fatalf("unexpected output: %s\nexpected: %s", buf.String(), expected) + } + if errBuf.String() != "" { + t.Fatalf("unexpected error output: %s", errBuf.String()) + } + if !verifiedPatch { + t.Fatal("No server-side patch call detected") + } + }) + } +} + +// TestUnstructuredIdempotentApply checks repeated apply operation on an unstructured object +func TestUnstructuredIdempotentApply(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + + serversideObject := readUnstructuredFromFile(t, filenameWidgetServerside) + serversideData, err := runtime.Encode(unstructured.JSONFallbackEncoder{Encoder: codec}, serversideObject) + if err != nil { + t.Fatal(err) + } + path := "/namespaces/test/widgets/widget" + + for _, fn := range testingOpenAPISchemaFns { + t.Run("test repeated apply operations on an unstructured object", func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == path && m == "GET": + body := ioutil.NopCloser(bytes.NewReader(serversideData)) + return &http.Response{ + StatusCode: 200, + Header: cmdtesting.DefaultHeader(), + Body: body}, nil + case p == path && m == "PATCH": + // In idempotent updates, kubectl will resolve to an empty patch and not send anything to the server + // Thus, if we reach this branch, kubectl is unnecessarily sending a patch. + + patch, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + t.Fatalf("Unexpected Patch: %s", patch) + return nil, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.OpenAPISchemaFunc = fn + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameWidgetClientside) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + expected := "widget.unit-test.test.com/widget\n" + if buf.String() != expected { + t.Fatalf("unexpected output: %s\nexpected: %s", buf.String(), expected) + } + if errBuf.String() != "" { + t.Fatalf("unexpected error output: %s", errBuf.String()) + } + }) + } +} + +func TestRunApplySetLastApplied(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + nameRC, currentRC := readAndAnnotateReplicationController(t, filenameRC) + pathRC := "/namespaces/test/replicationcontrollers/" + nameRC + + noExistRC, _ := readAndAnnotateReplicationController(t, filenameNoExistRC) + noExistPath := "/namespaces/test/replicationcontrollers/" + noExistRC + + noAnnotationName, noAnnotationRC := readReplicationController(t, filenameRCNoAnnotation) + noAnnotationPath := "/namespaces/test/replicationcontrollers/" + noAnnotationName + + tests := []struct { + name, nameRC, pathRC, filePath, expectedErr, expectedOut, output string + }{ + { + name: "set with exist object", + filePath: filenameRC, + expectedErr: "", + expectedOut: "replicationcontroller/test-rc\n", + output: "name", + }, + { + name: "set with no-exist object", + filePath: filenameNoExistRC, + expectedErr: "Error from server (NotFound): the server could not find the requested resource (get replicationcontrollers no-exist)", + expectedOut: "", + output: "name", + }, + { + name: "set for the annotation does not exist on the live object", + filePath: filenameRCNoAnnotation, + expectedErr: "error: no last-applied-configuration annotation found on resource: no-annotation, to create the annotation, run the command with --create-annotation", + expectedOut: "", + output: "name", + }, + { + name: "set with exist object output json", + filePath: filenameRCJSON, + expectedErr: "", + expectedOut: "replicationcontroller/test-rc\n", + output: "name", + }, + { + name: "set test for a directory of files", + filePath: dirName, + expectedErr: "", + expectedOut: "replicationcontroller/test-rc\nreplicationcontroller/test-rc\n", + output: "name", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == pathRC && m == "GET": + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == noAnnotationPath && m == "GET": + bodyRC := ioutil.NopCloser(bytes.NewReader(noAnnotationRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == noExistPath && m == "GET": + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.Pod{})}, nil + case p == pathRC && m == "PATCH": + checkPatchString(t, req) + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case p == "/api/v1/namespaces/test" && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.Namespace{})}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + cmdutil.BehaviorOnFatal(func(str string, code int) { + if str != test.expectedErr { + t.Errorf("%s: unexpected error: %s\nexpected: %s", test.name, str, test.expectedErr) + } + }) + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdApplySetLastApplied(tf, ioStreams) + cmd.Flags().Set("filename", test.filePath) + cmd.Flags().Set("output", test.output) + cmd.Run(cmd, []string{}) + + if buf.String() != test.expectedOut { + t.Fatalf("%s: unexpected output: %s\nexpected: %s", test.name, buf.String(), test.expectedOut) + } + }) + } + cmdutil.BehaviorOnFatal(func(str string, code int) {}) +} + +func checkPatchString(t *testing.T, req *http.Request) { + checkString := string(readBytesFromFile(t, filenameRCPatchTest)) + patch, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + + patchMap := map[string]interface{}{} + if err := json.Unmarshal(patch, &patchMap); err != nil { + t.Fatal(err) + } + + annotationsMap := walkMapPath(t, patchMap, []string{"metadata", "annotations"}) + if _, ok := annotationsMap[corev1.LastAppliedConfigAnnotation]; !ok { + t.Fatalf("patch does not contain annotation:\n%s\n", patch) + } + + resultString := annotationsMap["kubectl.kubernetes.io/last-applied-configuration"] + if resultString != checkString { + t.Fatalf("patch annotation is not correct, expect:%s\n but got:%s\n", checkString, resultString) + } +} + +func TestForceApply(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + scheme := runtime.NewScheme() + nameRC, currentRC := readAndAnnotateReplicationController(t, filenameRC) + pathRC := "/namespaces/test/replicationcontrollers/" + nameRC + pathRCList := "/namespaces/test/replicationcontrollers" + expected := map[string]int{ + "getOk": 6, + "getNotFound": 1, + "getList": 0, + "patch": 6, + "delete": 1, + "post": 1, + } + + for _, fn := range testingOpenAPISchemaFns { + t.Run("test apply with --force", func(t *testing.T) { + deleted := false + isScaledDownToZero := false + counts := map[string]int{} + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case strings.HasSuffix(p, pathRC) && m == "GET": + if deleted { + counts["getNotFound"]++ + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte{}))}, nil + } + counts["getOk"]++ + var bodyRC io.ReadCloser + if isScaledDownToZero { + rcObj := readReplicationControllerFromFile(t, filenameRC) + rcObj.Spec.Replicas = utilpointer.Int32Ptr(0) + rcBytes, err := runtime.Encode(codec, rcObj) + if err != nil { + t.Fatal(err) + } + bodyRC = ioutil.NopCloser(bytes.NewReader(rcBytes)) + } else { + bodyRC = ioutil.NopCloser(bytes.NewReader(currentRC)) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case strings.HasSuffix(p, pathRCList) && m == "GET": + counts["getList"]++ + rcObj := readUnstructuredFromFile(t, filenameRC) + list := &unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ReplicationControllerList", + }, + Items: []unstructured.Unstructured{*rcObj}, + } + listBytes, err := runtime.Encode(codec, list) + if err != nil { + t.Fatal(err) + } + bodyRCList := ioutil.NopCloser(bytes.NewReader(listBytes)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRCList}, nil + case strings.HasSuffix(p, pathRC) && m == "PATCH": + counts["patch"]++ + if counts["patch"] <= 6 { + statusErr := kubeerr.NewConflict(schema.GroupResource{Group: "", Resource: "rc"}, "test-rc", fmt.Errorf("the object has been modified. Please apply at first")) + bodyBytes, _ := json.Marshal(statusErr) + bodyErr := ioutil.NopCloser(bytes.NewReader(bodyBytes)) + return &http.Response{StatusCode: http.StatusConflict, Header: cmdtesting.DefaultHeader(), Body: bodyErr}, nil + } + t.Fatalf("unexpected request: %#v after %v tries\n%#v", req.URL, counts["patch"], req) + return nil, nil + case strings.HasSuffix(p, pathRC) && m == "PUT": + counts["put"]++ + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + isScaledDownToZero = true + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + case strings.HasSuffix(p, pathRCList) && m == "POST": + counts["post"]++ + deleted = false + isScaledDownToZero = false + bodyRC := ioutil.NopCloser(bytes.NewReader(currentRC)) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: bodyRC}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + fakeDynamicClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeDynamicClient.PrependReactor("delete", "replicationcontrollers", func(action clienttesting.Action) (bool, runtime.Object, error) { + if deleteAction, ok := action.(clienttesting.DeleteAction); ok { + if deleteAction.GetName() == nameRC { + counts["delete"]++ + deleted = true + return true, nil, nil + } + } + return false, nil, nil + }) + tf.FakeDynamicClient = fakeDynamicClient + tf.OpenAPISchemaFunc = fn + tf.Client = tf.UnstructuredClient + tf.ClientConfigVal = &restclient.Config{} + + ioStreams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdApply("kubectl", tf, ioStreams) + cmd.Flags().Set("filename", filenameRC) + cmd.Flags().Set("output", "name") + cmd.Flags().Set("force", "true") + cmd.Run(cmd, []string{}) + + for method, exp := range expected { + if exp != counts[method] { + t.Errorf("Unexpected amount of %q API calls, wanted %v got %v", method, exp, counts[method]) + } + } + + if expected := "replicationcontroller/" + nameRC + "\n"; buf.String() != expected { + t.Fatalf("unexpected output: %s\nexpected: %s", buf.String(), expected) + } + if errBuf.String() != "" { + t.Fatalf("unexpected error output: %s", errBuf.String()) + } + }) + } +} + +func TestDryRunVerifier(t *testing.T) { + dryRunVerifier := DryRunVerifier{ + Finder: cmdutil.NewCRDFinder(func() ([]schema.GroupKind, error) { + return []schema.GroupKind{ + { + Group: "crd.com", + Kind: "MyCRD", + }, + { + Group: "crd.com", + Kind: "MyNewCRD", + }, + }, nil + }), + OpenAPIGetter: &fakeSchema, + } + + err := dryRunVerifier.HasSupport(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "NodeProxyOptions"}) + if err == nil { + t.Fatalf("NodeProxyOptions doesn't support dry-run, yet no error found") + } + + err = dryRunVerifier.HasSupport(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}) + if err != nil { + t.Fatalf("Pod should support dry-run: %v", err) + } + + err = dryRunVerifier.HasSupport(schema.GroupVersionKind{Group: "crd.com", Version: "v1", Kind: "MyCRD"}) + if err != nil { + t.Fatalf("MyCRD should support dry-run: %v", err) + } + + err = dryRunVerifier.HasSupport(schema.GroupVersionKind{Group: "crd.com", Version: "v1", Kind: "Random"}) + if err == nil { + t.Fatalf("Random doesn't support dry-run, yet no error found") + } +} + +type EmptyOpenAPI struct{} + +func (EmptyOpenAPI) OpenAPISchema() (*openapi_v2.Document, error) { + return &openapi_v2.Document{}, nil +} + +func TestDryRunVerifierNoOpenAPI(t *testing.T) { + dryRunVerifier := DryRunVerifier{ + Finder: cmdutil.NewCRDFinder(func() ([]schema.GroupKind, error) { + return []schema.GroupKind{ + { + Group: "crd.com", + Kind: "MyCRD", + }, + { + Group: "crd.com", + Kind: "MyNewCRD", + }, + }, nil + }), + OpenAPIGetter: EmptyOpenAPI{}, + } + + err := dryRunVerifier.HasSupport(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}) + if err == nil { + t.Fatalf("Pod doesn't support dry-run, yet no error found") + } + + err = dryRunVerifier.HasSupport(schema.GroupVersionKind{Group: "crd.com", Version: "v1", Kind: "MyCRD"}) + if err == nil { + t.Fatalf("MyCRD doesn't support dry-run, yet no error found") + } +} diff --git a/pkg/cmd/apply/apply_view_last_applied.go b/pkg/cmd/apply/apply_view_last_applied.go new file mode 100644 index 000000000..4327ea6ae --- /dev/null +++ b/pkg/cmd/apply/apply_view_last_applied.go @@ -0,0 +1,172 @@ +/* +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 apply + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "sigs.k8s.io/yaml" +) + +// ViewLastAppliedOptions defines options for the `apply view-last-applied` command.` +type ViewLastAppliedOptions struct { + FilenameOptions resource.FilenameOptions + Selector string + LastAppliedConfigurationList []string + OutputFormat string + All bool + Factory cmdutil.Factory + + genericclioptions.IOStreams +} + +var ( + applyViewLastAppliedLong = templates.LongDesc(i18n.T(` + View the latest last-applied-configuration annotations by type/name or file. + + The default output will be printed to stdout in YAML format. One can use -o option + to change output format.`)) + + applyViewLastAppliedExample = templates.Examples(i18n.T(` + # View the last-applied-configuration annotations by type/name in YAML. + kubectl apply view-last-applied deployment/nginx + + # View the last-applied-configuration annotations by file in JSON + kubectl apply view-last-applied -f deploy.yaml -o json`)) +) + +// NewViewLastAppliedOptions takes option arguments from a CLI stream and returns it at ViewLastAppliedOptions type. +func NewViewLastAppliedOptions(ioStreams genericclioptions.IOStreams) *ViewLastAppliedOptions { + return &ViewLastAppliedOptions{ + OutputFormat: "yaml", + + IOStreams: ioStreams, + } +} + +// NewCmdApplyViewLastApplied creates the cobra CLI `apply` subcommand `view-last-applied`.` +func NewCmdApplyViewLastApplied(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := NewViewLastAppliedOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "view-last-applied (TYPE [NAME | -l label] | TYPE/NAME | -f FILENAME)", + DisableFlagsInUseLine: true, + Short: i18n.T("View latest last-applied-configuration annotations of a resource/object"), + Long: applyViewLastAppliedLong, + Example: applyViewLastAppliedExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(cmd, f, args)) + cmdutil.CheckErr(options.Validate(cmd)) + cmdutil.CheckErr(options.RunApplyViewLastApplied(cmd)) + }, + } + + cmd.Flags().StringVarP(&options.OutputFormat, "output", "o", options.OutputFormat, "Output format. Must be one of yaml|json") + cmd.Flags().StringVarP(&options.Selector, "selector", "l", options.Selector, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().BoolVar(&options.All, "all", options.All, "Select all resources in the namespace of the specified resource types") + usage := "that contains the last-applied-configuration annotations" + cmdutil.AddFilenameOptionFlags(cmd, &options.FilenameOptions, usage) + + return cmd +} + +// Complete checks an object for last-applied-configuration annotations. +func (o *ViewLastAppliedOptions) Complete(cmd *cobra.Command, f cmdutil.Factory, args []string) error { + cmdNamespace, enforceNamespace, err := f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + r := f.NewBuilder(). + Unstructured(). + NamespaceParam(cmdNamespace).DefaultNamespace(). + FilenameParam(enforceNamespace, &o.FilenameOptions). + ResourceTypeOrNameArgs(enforceNamespace, args...). + SelectAllParam(o.All). + LabelSelectorParam(o.Selector). + Latest(). + Flatten(). + Do() + err = r.Err() + if err != nil { + return err + } + + err = r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + configString, err := util.GetOriginalConfiguration(info.Object) + if err != nil { + return err + } + if configString == nil { + return cmdutil.AddSourceToErr(fmt.Sprintf("no last-applied-configuration annotation found on resource: %s\n", info.Name), info.Source, err) + } + o.LastAppliedConfigurationList = append(o.LastAppliedConfigurationList, string(configString)) + return nil + }) + + if err != nil { + return err + } + + return nil +} + +// Validate checks ViewLastAppliedOptions for validity. +func (o *ViewLastAppliedOptions) Validate(cmd *cobra.Command) error { + return nil +} + +// RunApplyViewLastApplied executes the `view-last-applied` command according to ViewLastAppliedOptions. +func (o *ViewLastAppliedOptions) RunApplyViewLastApplied(cmd *cobra.Command) error { + for _, str := range o.LastAppliedConfigurationList { + switch o.OutputFormat { + case "json": + jsonBuffer := &bytes.Buffer{} + err := json.Indent(jsonBuffer, []byte(str), "", " ") + if err != nil { + return err + } + fmt.Fprint(o.Out, string(jsonBuffer.Bytes())) + case "yaml": + yamlOutput, err := yaml.JSONToYAML([]byte(str)) + if err != nil { + return err + } + fmt.Fprint(o.Out, string(yamlOutput)) + default: + return cmdutil.UsageErrorf( + cmd, + "Unexpected -o output mode: %s, the flag 'output' must be one of yaml|json", + o.OutputFormat) + } + } + + return nil +} diff --git a/pkg/cmd/attach/attach.go b/pkg/cmd/attach/attach.go new file mode 100644 index 000000000..0b79170dd --- /dev/null +++ b/pkg/cmd/attach/attach.go @@ -0,0 +1,344 @@ +/* +Copyright 2014 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 attach + +import ( + "fmt" + "io" + "net/url" + "time" + + "github.com/spf13/cobra" + "k8s.io/klog" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubernetes/pkg/kubectl/cmd/exec" +) + +var ( + attachExample = templates.Examples(i18n.T(` + # Get output from running pod 123456-7890, using the first container by default + kubectl attach 123456-7890 + + # Get output from ruby-container from pod 123456-7890 + kubectl attach 123456-7890 -c ruby-container + + # Switch to raw terminal mode, sends stdin to 'bash' in ruby-container from pod 123456-7890 + # and sends stdout/stderr from 'bash' back to the client + kubectl attach 123456-7890 -c ruby-container -i -t + + # Get output from the first pod of a ReplicaSet named nginx + kubectl attach rs/nginx + `)) +) + +const ( + defaultPodAttachTimeout = 60 * time.Second + defaultPodLogsTimeout = 20 * time.Second +) + +// AttachOptions declare the arguments accepted by the Attach command +type AttachOptions struct { + exec.StreamOptions + + // whether to disable use of standard error when streaming output from tty + DisableStderr bool + + CommandName string + ParentCommandName string + EnableSuggestedCmdUsage bool + + Pod *corev1.Pod + + AttachFunc func(*AttachOptions, *corev1.Container, bool, remotecommand.TerminalSizeQueue) func() error + Resources []string + Builder func() *resource.Builder + AttachablePodFn polymorphichelpers.AttachablePodForObjectFunc + restClientGetter genericclioptions.RESTClientGetter + + Attach RemoteAttach + GetPodTimeout time.Duration + Config *restclient.Config +} + +// NewAttachOptions creates the options for attach +func NewAttachOptions(streams genericclioptions.IOStreams) *AttachOptions { + return &AttachOptions{ + StreamOptions: exec.StreamOptions{ + IOStreams: streams, + }, + Attach: &DefaultRemoteAttach{}, + AttachFunc: DefaultAttachFunc, + } +} + +// NewCmdAttach returns the attach Cobra command +func NewCmdAttach(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewAttachOptions(streams) + cmd := &cobra.Command{ + Use: "attach (POD | TYPE/NAME) -c CONTAINER", + DisableFlagsInUseLine: true, + Short: i18n.T("Attach to a running container"), + Long: "Attach to a process that is already running inside an existing container.", + Example: attachExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run()) + }, + } + cmdutil.AddPodRunningTimeoutFlag(cmd, defaultPodAttachTimeout) + cmd.Flags().StringVarP(&o.ContainerName, "container", "c", o.ContainerName, "Container name. If omitted, the first container in the pod will be chosen") + cmd.Flags().BoolVarP(&o.Stdin, "stdin", "i", o.Stdin, "Pass stdin to the container") + cmd.Flags().BoolVarP(&o.TTY, "tty", "t", o.TTY, "Stdin is a TTY") + return cmd +} + +// RemoteAttach defines the interface accepted by the Attach command - provided for test stubbing +type RemoteAttach interface { + Attach(method string, url *url.URL, config *restclient.Config, stdin io.Reader, stdout, stderr io.Writer, tty bool, terminalSizeQueue remotecommand.TerminalSizeQueue) error +} + +// DefaultAttachFunc is the default AttachFunc used +func DefaultAttachFunc(o *AttachOptions, containerToAttach *corev1.Container, raw bool, sizeQueue remotecommand.TerminalSizeQueue) func() error { + return func() error { + restClient, err := restclient.RESTClientFor(o.Config) + if err != nil { + return err + } + req := restClient.Post(). + Resource("pods"). + Name(o.Pod.Name). + Namespace(o.Pod.Namespace). + SubResource("attach") + req.VersionedParams(&corev1.PodAttachOptions{ + Container: containerToAttach.Name, + Stdin: o.Stdin, + Stdout: o.Out != nil, + Stderr: !o.DisableStderr, + TTY: raw, + }, scheme.ParameterCodec) + + return o.Attach.Attach("POST", req.URL(), o.Config, o.In, o.Out, o.ErrOut, raw, sizeQueue) + } +} + +// DefaultRemoteAttach is the standard implementation of attaching +type DefaultRemoteAttach struct{} + +// Attach executes attach to a running container +func (*DefaultRemoteAttach) Attach(method string, url *url.URL, config *restclient.Config, stdin io.Reader, stdout, stderr io.Writer, tty bool, terminalSizeQueue remotecommand.TerminalSizeQueue) error { + exec, err := remotecommand.NewSPDYExecutor(config, method, url) + if err != nil { + return err + } + return exec.Stream(remotecommand.StreamOptions{ + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + Tty: tty, + TerminalSizeQueue: terminalSizeQueue, + }) +} + +// Complete verifies command line arguments and loads data from the command environment +func (o *AttachOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.AttachablePodFn = polymorphichelpers.AttachablePodForObjectFn + + o.GetPodTimeout, err = cmdutil.GetPodRunningTimeoutFlag(cmd) + if err != nil { + return cmdutil.UsageErrorf(cmd, err.Error()) + } + + o.Builder = f.NewBuilder + o.Resources = args + o.restClientGetter = f + + cmdParent := cmd.Parent() + if cmdParent != nil { + o.ParentCommandName = cmdParent.CommandPath() + } + if len(o.ParentCommandName) > 0 && cmdutil.IsSiblingCommandExists(cmd, "describe") { + o.EnableSuggestedCmdUsage = true + } + + config, err := f.ToRESTConfig() + if err != nil { + return err + } + o.Config = config + + if o.CommandName == "" { + o.CommandName = cmd.CommandPath() + } + + return nil +} + +// Validate checks that the provided attach options are specified. +func (o *AttachOptions) Validate() error { + if len(o.Resources) == 0 { + return fmt.Errorf("at least 1 argument is required for attach") + } + if len(o.Resources) > 2 { + return fmt.Errorf("expected POD, TYPE/NAME, or TYPE NAME, (at most 2 arguments) saw %d: %v", len(o.Resources), o.Resources) + } + if o.GetPodTimeout <= 0 { + return fmt.Errorf("--pod-running-timeout must be higher than zero") + } + + return nil +} + +// Run executes a validated remote execution against a pod. +func (o *AttachOptions) Run() error { + if o.Pod == nil { + b := o.Builder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace() + + switch len(o.Resources) { + case 1: + b.ResourceNames("pods", o.Resources[0]) + case 2: + b.ResourceNames(o.Resources[0], o.Resources[1]) + } + + obj, err := b.Do().Object() + if err != nil { + return err + } + + o.Pod, err = o.findAttachablePod(obj) + if err != nil { + return err + } + + if o.Pod.Status.Phase == corev1.PodSucceeded || o.Pod.Status.Phase == corev1.PodFailed { + return fmt.Errorf("cannot attach a container in a completed pod; current phase is %s", o.Pod.Status.Phase) + } + // TODO: convert this to a clean "wait" behavior + } + + // check for TTY + containerToAttach, err := o.containerToAttachTo(o.Pod) + if err != nil { + return fmt.Errorf("cannot attach to the container: %v", err) + } + if o.TTY && !containerToAttach.TTY { + o.TTY = false + if o.ErrOut != nil { + fmt.Fprintf(o.ErrOut, "Unable to use a TTY - container %s did not allocate one\n", containerToAttach.Name) + } + } else if !o.TTY && containerToAttach.TTY { + // the container was launched with a TTY, so we have to force a TTY here, otherwise you'll get + // an error "Unrecognized input header" + o.TTY = true + } + + // ensure we can recover the terminal while attached + t := o.SetupTTY() + + var sizeQueue remotecommand.TerminalSizeQueue + if t.Raw { + if size := t.GetSize(); size != nil { + // fake resizing +1 and then back to normal so that attach-detach-reattach will result in the + // screen being redrawn + sizePlusOne := *size + sizePlusOne.Width++ + sizePlusOne.Height++ + + // this call spawns a goroutine to monitor/update the terminal size + sizeQueue = t.MonitorSize(&sizePlusOne, size) + } + + o.DisableStderr = true + } + + if !o.Quiet { + fmt.Fprintln(o.ErrOut, "If you don't see a command prompt, try pressing enter.") + } + if err := t.Safe(o.AttachFunc(o, containerToAttach, t.Raw, sizeQueue)); err != nil { + return err + } + + if o.Stdin && t.Raw && o.Pod.Spec.RestartPolicy == corev1.RestartPolicyAlways { + fmt.Fprintf(o.Out, "Session ended, resume using '%s %s -c %s -i -t' command when the pod is running\n", o.CommandName, o.Pod.Name, containerToAttach.Name) + } + return nil +} + +func (o *AttachOptions) findAttachablePod(obj runtime.Object) (*corev1.Pod, error) { + attachablePod, err := o.AttachablePodFn(o.restClientGetter, obj, o.GetPodTimeout) + if err != nil { + return nil, err + } + + o.StreamOptions.PodName = attachablePod.Name + return attachablePod, nil +} + +// containerToAttach returns a reference to the container to attach to, given +// by name or the first container if name is empty. +func (o *AttachOptions) containerToAttachTo(pod *corev1.Pod) (*corev1.Container, error) { + if len(o.ContainerName) > 0 { + for i := range pod.Spec.Containers { + if pod.Spec.Containers[i].Name == o.ContainerName { + return &pod.Spec.Containers[i], nil + } + } + for i := range pod.Spec.InitContainers { + if pod.Spec.InitContainers[i].Name == o.ContainerName { + return &pod.Spec.InitContainers[i], nil + } + } + return nil, fmt.Errorf("container not found (%s)", o.ContainerName) + } + + if o.EnableSuggestedCmdUsage { + fmt.Fprintf(o.ErrOut, "Defaulting container name to %s.\n", pod.Spec.Containers[0].Name) + fmt.Fprintf(o.ErrOut, "Use '%s describe pod/%s -n %s' to see all of the containers in this pod.\n", o.ParentCommandName, o.PodName, o.Namespace) + } + + klog.V(4).Infof("defaulting container name to %s", pod.Spec.Containers[0].Name) + return &pod.Spec.Containers[0], nil +} + +// GetContainerName returns the name of the container to attach to, with a fallback. +func (o *AttachOptions) GetContainerName(pod *corev1.Pod) (string, error) { + c, err := o.containerToAttachTo(pod) + if err != nil { + return "", err + } + return c.Name, nil +} diff --git a/pkg/cmd/attach/attach_test.go b/pkg/cmd/attach/attach_test.go new file mode 100644 index 000000000..1c28a540c --- /dev/null +++ b/pkg/cmd/attach/attach_test.go @@ -0,0 +1,422 @@ +/* +Copyright 2014 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 attach + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/client-go/tools/remotecommand" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubernetes/pkg/kubectl/cmd/exec" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +type fakeRemoteAttach struct { + method string + url *url.URL + err error +} + +func (f *fakeRemoteAttach) Attach(method string, url *url.URL, config *restclient.Config, stdin io.Reader, stdout, stderr io.Writer, tty bool, terminalSizeQueue remotecommand.TerminalSizeQueue) error { + f.method = method + f.url = url + return f.err +} + +func fakeAttachablePodFn(pod *corev1.Pod) polymorphichelpers.AttachablePodForObjectFunc { + return func(getter genericclioptions.RESTClientGetter, obj runtime.Object, timeout time.Duration) (*corev1.Pod, error) { + return pod, nil + } +} + +func TestPodAndContainerAttach(t *testing.T) { + tests := []struct { + name string + args []string + options *AttachOptions + expectError string + expectedPodName string + expectedContainerName string + obj *corev1.Pod + }{ + { + name: "empty", + options: &AttachOptions{GetPodTimeout: 1}, + expectError: "at least 1 argument is required", + }, + { + name: "too many args", + options: &AttachOptions{GetPodTimeout: 2}, + args: []string{"one", "two", "three"}, + expectError: "at most 2 arguments", + }, + { + name: "no container, no flags", + options: &AttachOptions{GetPodTimeout: defaultPodLogsTimeout}, + args: []string{"foo"}, + expectedPodName: "foo", + expectedContainerName: "bar", + obj: attachPod(), + }, + { + name: "container in flag", + options: &AttachOptions{StreamOptions: exec.StreamOptions{ContainerName: "bar"}, GetPodTimeout: 10000000}, + args: []string{"foo"}, + expectedPodName: "foo", + expectedContainerName: "bar", + obj: attachPod(), + }, + { + name: "init container in flag", + options: &AttachOptions{StreamOptions: exec.StreamOptions{ContainerName: "initfoo"}, GetPodTimeout: 30}, + args: []string{"foo"}, + expectedPodName: "foo", + expectedContainerName: "initfoo", + obj: attachPod(), + }, + { + name: "non-existing container", + options: &AttachOptions{StreamOptions: exec.StreamOptions{ContainerName: "wrong"}, GetPodTimeout: 10}, + args: []string{"foo"}, + expectedPodName: "foo", + expectError: "container not found", + obj: attachPod(), + }, + { + name: "no container, no flags, pods and name", + options: &AttachOptions{GetPodTimeout: 10000}, + args: []string{"pods", "foo"}, + expectedPodName: "foo", + expectedContainerName: "bar", + obj: attachPod(), + }, + { + name: "invalid get pod timeout value", + options: &AttachOptions{GetPodTimeout: 0}, + args: []string{"pod/foo"}, + expectedPodName: "foo", + expectedContainerName: "bar", + obj: attachPod(), + expectError: "must be higher than zero", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // setup opts to fetch our test pod + test.options.AttachablePodFn = fakeAttachablePodFn(test.obj) + test.options.Resources = test.args + + if err := test.options.Validate(); err != nil { + if !strings.Contains(err.Error(), test.expectError) { + t.Errorf("unexpected error: expected %q, got %q", test.expectError, err) + } + return + } + + pod, err := test.options.findAttachablePod(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod", Namespace: "test"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "foobar", + }, + }, + }, + }) + if err != nil { + if !strings.Contains(err.Error(), test.expectError) { + t.Errorf("unexpected error: expected %q, got %q", err, test.expectError) + } + return + } + + if pod.Name != test.expectedPodName { + t.Errorf("unexpected pod name: expected %q, got %q", test.expectedContainerName, pod.Name) + } + + container, err := test.options.containerToAttachTo(attachPod()) + if err != nil { + if !strings.Contains(err.Error(), test.expectError) { + t.Errorf("unexpected error: expected %q, got %q", err, test.expectError) + } + return + } + + if container.Name != test.expectedContainerName { + t.Errorf("unexpected container name: expected %q, got %q", test.expectedContainerName, container.Name) + } + + if test.options.PodName != test.expectedPodName { + t.Errorf("%s: expected: %s, got: %s", test.name, test.expectedPodName, test.options.PodName) + } + + if len(test.expectError) > 0 { + t.Fatalf("expected error %q, but saw none", test.expectError) + } + }) + } +} + +func TestAttach(t *testing.T) { + version := "v1" + tests := []struct { + name, version, podPath, fetchPodPath, attachPath, container string + pod *corev1.Pod + remoteAttachErr bool + exepctedErr string + }{ + { + name: "pod attach", + version: version, + podPath: "/api/" + version + "/namespaces/test/pods/foo", + fetchPodPath: "/namespaces/test/pods/foo", + attachPath: "/api/" + version + "/namespaces/test/pods/foo/attach", + pod: attachPod(), + container: "bar", + }, + { + name: "pod attach error", + version: version, + podPath: "/api/" + version + "/namespaces/test/pods/foo", + fetchPodPath: "/namespaces/test/pods/foo", + attachPath: "/api/" + version + "/namespaces/test/pods/foo/attach", + pod: attachPod(), + remoteAttachErr: true, + container: "bar", + exepctedErr: "attach error", + }, + { + name: "container not found error", + version: version, + podPath: "/api/" + version + "/namespaces/test/pods/foo", + fetchPodPath: "/namespaces/test/pods/foo", + attachPath: "/api/" + version + "/namespaces/test/pods/foo/attach", + pod: attachPod(), + container: "foo", + exepctedErr: "cannot attach to the container: container not found (foo)", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == test.podPath && m == "GET": + body := cmdtesting.ObjBody(codec, test.pod) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + case p == test.fetchPodPath && m == "GET": + body := cmdtesting.ObjBody(codec, test.pod) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Errorf("%s: unexpected request: %s %#v\n%#v", p, req.Method, req.URL, req) + return nil, fmt.Errorf("unexpected request") + } + }), + } + tf.ClientConfigVal = &restclient.Config{APIPath: "/api", ContentConfig: restclient.ContentConfig{NegotiatedSerializer: scheme.Codecs, GroupVersion: &schema.GroupVersion{Version: test.version}}} + + remoteAttach := &fakeRemoteAttach{} + if test.remoteAttachErr { + remoteAttach.err = fmt.Errorf("attach error") + } + options := &AttachOptions{ + StreamOptions: exec.StreamOptions{ + ContainerName: test.container, + IOStreams: genericclioptions.NewTestIOStreamsDiscard(), + }, + Attach: remoteAttach, + GetPodTimeout: 1000, + } + + options.restClientGetter = tf + options.Namespace = "test" + options.Resources = []string{"foo"} + options.Builder = tf.NewBuilder + options.AttachablePodFn = fakeAttachablePodFn(test.pod) + options.AttachFunc = func(opts *AttachOptions, containerToAttach *corev1.Container, raw bool, sizeQueue remotecommand.TerminalSizeQueue) func() error { + return func() error { + u, err := url.Parse(fmt.Sprintf("%s?container=%s", test.attachPath, containerToAttach.Name)) + if err != nil { + return err + } + + return options.Attach.Attach("POST", u, nil, nil, nil, nil, raw, sizeQueue) + } + } + + err := options.Run() + if test.exepctedErr != "" && err.Error() != test.exepctedErr { + t.Errorf("%s: Unexpected exec error: %v", test.name, err) + return + } + if test.exepctedErr == "" && err != nil { + t.Errorf("%s: Unexpected error: %v", test.name, err) + return + } + if test.exepctedErr != "" { + return + } + if remoteAttach.url.Path != test.attachPath { + t.Errorf("%s: Did not get expected path for exec request: %q %q", test.name, test.attachPath, remoteAttach.url.Path) + return + } + if remoteAttach.method != "POST" { + t.Errorf("%s: Did not get method for attach request: %s", test.name, remoteAttach.method) + } + if remoteAttach.url.Query().Get("container") != "bar" { + t.Errorf("%s: Did not have query parameters: %s", test.name, remoteAttach.url.Query()) + } + }) + } +} + +func TestAttachWarnings(t *testing.T) { + version := "v1" + tests := []struct { + name, container, version, podPath, fetchPodPath, expectedErr string + pod *corev1.Pod + stdin, tty bool + }{ + { + name: "fallback tty if not supported", + version: version, + podPath: "/api/" + version + "/namespaces/test/pods/foo", + fetchPodPath: "/namespaces/test/pods/foo", + pod: attachPod(), + stdin: true, + tty: true, + expectedErr: "Unable to use a TTY - container bar did not allocate one", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + streams, _, _, bufErr := genericclioptions.NewTestIOStreams() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == test.podPath && m == "GET": + body := cmdtesting.ObjBody(codec, test.pod) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + case p == test.fetchPodPath && m == "GET": + body := cmdtesting.ObjBody(codec, test.pod) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Errorf("%s: unexpected request: %s %#v\n%#v", p, req.Method, req.URL, req) + return nil, fmt.Errorf("unexpected request") + } + }), + } + tf.ClientConfigVal = &restclient.Config{APIPath: "/api", ContentConfig: restclient.ContentConfig{NegotiatedSerializer: scheme.Codecs, GroupVersion: &schema.GroupVersion{Version: test.version}}} + + options := &AttachOptions{ + StreamOptions: exec.StreamOptions{ + Stdin: test.stdin, + TTY: test.tty, + ContainerName: test.container, + IOStreams: streams, + }, + + Attach: &fakeRemoteAttach{}, + GetPodTimeout: 1000, + } + + options.restClientGetter = tf + options.Namespace = "test" + options.Resources = []string{"foo"} + options.Builder = tf.NewBuilder + options.AttachablePodFn = fakeAttachablePodFn(test.pod) + options.AttachFunc = func(opts *AttachOptions, containerToAttach *corev1.Container, raw bool, sizeQueue remotecommand.TerminalSizeQueue) func() error { + return func() error { + u, err := url.Parse("http://foo.bar") + if err != nil { + return err + } + + return options.Attach.Attach("POST", u, nil, nil, nil, nil, raw, sizeQueue) + } + } + + if err := options.Run(); err != nil { + t.Fatal(err) + } + + if test.stdin && test.tty { + if !test.pod.Spec.Containers[0].TTY { + if !strings.Contains(bufErr.String(), test.expectedErr) { + t.Errorf("%s: Expected TTY fallback warning for attach request: %s", test.name, bufErr.String()) + return + } + } + } + }) + } +} + +func attachPod() *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "test", ResourceVersion: "10"}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + DNSPolicy: corev1.DNSClusterFirst, + Containers: []corev1.Container{ + { + Name: "bar", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "initfoo", + }, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } +} diff --git a/pkg/cmd/autoscale/autoscale.go b/pkg/cmd/autoscale/autoscale.go new file mode 100644 index 000000000..9283b7335 --- /dev/null +++ b/pkg/cmd/autoscale/autoscale.go @@ -0,0 +1,284 @@ +/* +Copyright 2015 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 autoscale + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/klog" + + autoscalingv1 "k8s.io/api/autoscaling/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + autoscalingv1client "k8s.io/client-go/kubernetes/typed/autoscaling/v1" + "k8s.io/client-go/scale" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + autoscaleLong = templates.LongDesc(i18n.T(` + Creates an autoscaler that automatically chooses and sets the number of pods that run in a kubernetes cluster. + + Looks up a Deployment, ReplicaSet, StatefulSet, or ReplicationController by name and creates an autoscaler that uses the given resource as a reference. + An autoscaler can automatically increase or decrease number of pods deployed within the system as needed.`)) + + autoscaleExample = templates.Examples(i18n.T(` + # Auto scale a deployment "foo", with the number of pods between 2 and 10, no target CPU utilization specified so a default autoscaling policy will be used: + kubectl autoscale deployment foo --min=2 --max=10 + + # Auto scale a replication controller "foo", with the number of pods between 1 and 5, target CPU utilization at 80%: + kubectl autoscale rc foo --max=5 --cpu-percent=80`)) +) + +// AutoscaleOptions declare the arguments accepted by the Autoscale command +type AutoscaleOptions struct { + FilenameOptions *resource.FilenameOptions + + RecordFlags *genericclioptions.RecordFlags + Recorder genericclioptions.Recorder + + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + Name string + Generator string + Min int32 + Max int32 + CPUPercent int32 + + createAnnotation bool + args []string + enforceNamespace bool + namespace string + dryRun bool + builder *resource.Builder + generatorFunc func(string, *meta.RESTMapping) (generate.StructuredGenerator, error) + + HPAClient autoscalingv1client.HorizontalPodAutoscalersGetter + scaleKindResolver scale.ScaleKindResolver + + genericclioptions.IOStreams +} + +// NewAutoscaleOptions creates the options for autoscale +func NewAutoscaleOptions(ioStreams genericclioptions.IOStreams) *AutoscaleOptions { + return &AutoscaleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("autoscaled").WithTypeSetter(scheme.Scheme), + FilenameOptions: &resource.FilenameOptions{}, + RecordFlags: genericclioptions.NewRecordFlags(), + Recorder: genericclioptions.NoopRecorder{}, + + IOStreams: ioStreams, + } +} + +// NewCmdAutoscale returns the autoscale Cobra command +func NewCmdAutoscale(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewAutoscaleOptions(ioStreams) + + validArgs := []string{"deployment", "replicaset", "replicationcontroller"} + + cmd := &cobra.Command{ + Use: "autoscale (-f FILENAME | TYPE NAME | TYPE/NAME) [--min=MINPODS] --max=MAXPODS [--cpu-percent=CPU]", + DisableFlagsInUseLine: true, + Short: i18n.T("Auto-scale a Deployment, ReplicaSet, or ReplicationController"), + Long: autoscaleLong, + Example: autoscaleExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run()) + }, + ValidArgs: validArgs, + } + + // bind flag structs + o.RecordFlags.AddFlags(cmd) + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().StringVar(&o.Generator, "generator", generateversioned.HorizontalPodAutoscalerV1GeneratorName, i18n.T("The name of the API generator to use. Currently there is only 1 generator.")) + cmd.Flags().Int32Var(&o.Min, "min", -1, "The lower limit for the number of pods that can be set by the autoscaler. If it's not specified or negative, the server will apply a default value.") + cmd.Flags().Int32Var(&o.Max, "max", -1, "The upper limit for the number of pods that can be set by the autoscaler. Required.") + cmd.MarkFlagRequired("max") + cmd.Flags().Int32Var(&o.CPUPercent, "cpu-percent", -1, fmt.Sprintf("The target average CPU utilization (represented as a percent of requested CPU) over all the pods. If it's not specified or negative, a default autoscaling policy will be used.")) + cmd.Flags().StringVar(&o.Name, "name", "", i18n.T("The name for the newly created object. If not specified, the name of the input resource will be used.")) + cmdutil.AddDryRunFlag(cmd) + cmdutil.AddFilenameOptionFlags(cmd, o.FilenameOptions, "identifying the resource to autoscale.") + cmdutil.AddApplyAnnotationFlags(cmd) + return cmd +} + +// Complete verifies command line arguments and loads data from the command environment +func (o *AutoscaleOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + o.dryRun = cmdutil.GetFlagBool(cmd, "dry-run") + o.createAnnotation = cmdutil.GetFlagBool(cmd, cmdutil.ApplyAnnotationsFlag) + o.builder = f.NewBuilder() + discoveryClient, err := f.ToDiscoveryClient() + if err != nil { + return err + } + o.scaleKindResolver = scale.NewDiscoveryScaleKindResolver(discoveryClient) + o.args = args + o.RecordFlags.Complete(cmd) + + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + kubeClient, err := f.KubernetesClientSet() + if err != nil { + return err + } + o.HPAClient = kubeClient.AutoscalingV1() + + // get the generator + o.generatorFunc = func(name string, mapping *meta.RESTMapping) (generate.StructuredGenerator, error) { + switch o.Generator { + case generateversioned.HorizontalPodAutoscalerV1GeneratorName: + return &generateversioned.HorizontalPodAutoscalerGeneratorV1{ + Name: name, + MinReplicas: o.Min, + MaxReplicas: o.Max, + CPUPercent: o.CPUPercent, + ScaleRefName: name, + ScaleRefKind: mapping.GroupVersionKind.Kind, + ScaleRefAPIVersion: mapping.GroupVersionKind.GroupVersion().String(), + }, nil + default: + return nil, cmdutil.UsageErrorf(cmd, "Generator %s not supported. ", o.Generator) + } + } + + o.namespace, o.enforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + if o.dryRun { + o.PrintFlags.Complete("%s (dry run)") + } + + return o.PrintFlags.ToPrinter() + } + + return nil +} + +// Validate checks that the provided attach options are specified. +func (o *AutoscaleOptions) Validate() error { + if o.Max < 1 { + return fmt.Errorf("--max=MAXPODS is required and must be at least 1, max: %d", o.Max) + } + if o.Max < o.Min { + return fmt.Errorf("--max=MAXPODS must be larger or equal to --min=MINPODS, max: %d, min: %d", o.Max, o.Min) + } + + return nil +} + +// Run performs the execution +func (o *AutoscaleOptions) Run() error { + r := o.builder. + Unstructured(). + ContinueOnError(). + NamespaceParam(o.namespace).DefaultNamespace(). + FilenameParam(o.enforceNamespace, o.FilenameOptions). + ResourceTypeOrNameArgs(false, o.args...). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + count := 0 + err := r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + mapping := info.ResourceMapping() + gvr := mapping.GroupVersionKind.GroupVersion().WithResource(mapping.Resource.Resource) + if _, err := o.scaleKindResolver.ScaleForResource(gvr); err != nil { + return fmt.Errorf("cannot autoscale a %v: %v", mapping.GroupVersionKind.Kind, err) + } + + generator, err := o.generatorFunc(info.Name, mapping) + if err != nil { + return err + } + + // Generate new object + object, err := generator.StructuredGenerate() + if err != nil { + return err + } + hpa, ok := object.(*autoscalingv1.HorizontalPodAutoscaler) + if !ok { + return fmt.Errorf("generator made %T, not autoscalingv1.HorizontalPodAutoscaler", object) + } + + if err := o.Recorder.Record(hpa); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + if o.dryRun { + count++ + + printer, err := o.ToPrinter("created") + if err != nil { + return err + } + return printer.PrintObj(hpa, o.Out) + } + + if err := util.CreateOrUpdateAnnotation(o.createAnnotation, hpa, scheme.DefaultJSONEncoder()); err != nil { + return err + } + + actualHPA, err := o.HPAClient.HorizontalPodAutoscalers(o.namespace).Create(hpa) + if err != nil { + return err + } + + count++ + printer, err := o.ToPrinter("autoscaled") + if err != nil { + return err + } + return printer.PrintObj(actualHPA, o.Out) + }) + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("no objects passed to autoscale") + } + return nil +} diff --git a/pkg/cmd/certificates/certificates.go b/pkg/cmd/certificates/certificates.go new file mode 100644 index 000000000..cacce27bc --- /dev/null +++ b/pkg/cmd/certificates/certificates.go @@ -0,0 +1,258 @@ +/* +Copyright 2016 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 certificates + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + certificatesv1beta1 "k8s.io/api/certificates/v1beta1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + certificatesv1beta1client "k8s.io/client-go/kubernetes/typed/certificates/v1beta1" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +func NewCmdCertificate(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "certificate SUBCOMMAND", + DisableFlagsInUseLine: true, + Short: i18n.T("Modify certificate resources."), + Long: "Modify certificate resources.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + + cmd.AddCommand(NewCmdCertificateApprove(f, ioStreams)) + cmd.AddCommand(NewCmdCertificateDeny(f, ioStreams)) + + return cmd +} + +type CertificateOptions struct { + resource.FilenameOptions + + PrintFlags *genericclioptions.PrintFlags + PrintObj printers.ResourcePrinterFunc + + csrNames []string + outputStyle string + + clientSet certificatesv1beta1client.CertificatesV1beta1Interface + builder *resource.Builder + + genericclioptions.IOStreams +} + +// NewCertificateOptions creates the options for certificate +func NewCertificateOptions(ioStreams genericclioptions.IOStreams) *CertificateOptions { + return &CertificateOptions{ + PrintFlags: genericclioptions.NewPrintFlags("approved").WithTypeSetter(scheme.Scheme), + IOStreams: ioStreams, + } +} + +func (o *CertificateOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + o.csrNames = args + o.outputStyle = cmdutil.GetFlagString(cmd, "output") + + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + + o.PrintObj = func(obj runtime.Object, out io.Writer) error { + return printer.PrintObj(obj, out) + } + + o.builder = f.NewBuilder() + + clientConfig, err := f.ToRESTConfig() + if err != nil { + return err + } + o.clientSet, err = certificatesv1beta1client.NewForConfig(clientConfig) + if err != nil { + return err + } + + return nil +} + +func (o *CertificateOptions) Validate() error { + if len(o.csrNames) < 1 && cmdutil.IsFilenameSliceEmpty(o.Filenames, o.Kustomize) { + return fmt.Errorf("one or more CSRs must be specified as or -f ") + } + return nil +} + +func NewCmdCertificateApprove(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewCertificateOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "approve (-f FILENAME | NAME)", + DisableFlagsInUseLine: true, + Short: i18n.T("Approve a certificate signing request"), + Long: templates.LongDesc(` + Approve a certificate signing request. + + kubectl certificate approve allows a cluster admin to approve a certificate + signing request (CSR). This action tells a certificate signing controller to + issue a certificate to the requestor with the attributes requested in the CSR. + + SECURITY NOTICE: Depending on the requested attributes, the issued certificate + can potentially grant a requester access to cluster resources or to authenticate + as a requested identity. Before approving a CSR, ensure you understand what the + signed certificate can do. + `), + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunCertificateApprove(cmdutil.GetFlagBool(cmd, "force"))) + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().Bool("force", false, "Update the CSR even if it is already approved.") + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, "identifying the resource to update") + + return cmd +} + +func (o *CertificateOptions) RunCertificateApprove(force bool) error { + return o.modifyCertificateCondition(o.builder, o.clientSet, force, func(csr *certificatesv1beta1.CertificateSigningRequest) (*certificatesv1beta1.CertificateSigningRequest, bool) { + var alreadyApproved bool + for _, c := range csr.Status.Conditions { + if c.Type == certificatesv1beta1.CertificateApproved { + alreadyApproved = true + } + } + if alreadyApproved { + return csr, true + } + csr.Status.Conditions = append(csr.Status.Conditions, certificatesv1beta1.CertificateSigningRequestCondition{ + Type: certificatesv1beta1.CertificateApproved, + Reason: "KubectlApprove", + Message: "This CSR was approved by kubectl certificate approve.", + LastUpdateTime: metav1.Now(), + }) + return csr, false + }) +} + +func NewCmdCertificateDeny(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewCertificateOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "deny (-f FILENAME | NAME)", + DisableFlagsInUseLine: true, + Short: i18n.T("Deny a certificate signing request"), + Long: templates.LongDesc(` + Deny a certificate signing request. + + kubectl certificate deny allows a cluster admin to deny a certificate + signing request (CSR). This action tells a certificate signing controller to + not to issue a certificate to the requestor. + `), + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunCertificateDeny(cmdutil.GetFlagBool(cmd, "force"))) + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().Bool("force", false, "Update the CSR even if it is already denied.") + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, "identifying the resource to update") + + return cmd +} + +func (o *CertificateOptions) RunCertificateDeny(force bool) error { + return o.modifyCertificateCondition(o.builder, o.clientSet, force, func(csr *certificatesv1beta1.CertificateSigningRequest) (*certificatesv1beta1.CertificateSigningRequest, bool) { + var alreadyDenied bool + for _, c := range csr.Status.Conditions { + if c.Type == certificatesv1beta1.CertificateDenied { + alreadyDenied = true + } + } + if alreadyDenied { + return csr, true + } + csr.Status.Conditions = append(csr.Status.Conditions, certificatesv1beta1.CertificateSigningRequestCondition{ + Type: certificatesv1beta1.CertificateDenied, + Reason: "KubectlDeny", + Message: "This CSR was denied by kubectl certificate deny.", + LastUpdateTime: metav1.Now(), + }) + return csr, false + }) +} + +func (o *CertificateOptions) modifyCertificateCondition(builder *resource.Builder, clientSet certificatesv1beta1client.CertificatesV1beta1Interface, force bool, modify func(csr *certificatesv1beta1.CertificateSigningRequest) (*certificatesv1beta1.CertificateSigningRequest, bool)) error { + var found int + r := builder. + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + ContinueOnError(). + FilenameParam(false, &o.FilenameOptions). + ResourceNames("certificatesigningrequest", o.csrNames...). + RequireObject(true). + Flatten(). + Latest(). + Do() + err := r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + for i := 0; ; i++ { + csr := info.Object.(*certificatesv1beta1.CertificateSigningRequest) + csr, hasCondition := modify(csr) + if !hasCondition || force { + csr, err = clientSet.CertificateSigningRequests().UpdateApproval(csr) + if errors.IsConflict(err) && i < 10 { + if err := info.Get(); err != nil { + return err + } + continue + } + if err != nil { + return err + } + } + break + } + found++ + + return o.PrintObj(info.Object, o.Out) + }) + if found == 0 { + fmt.Fprintf(o.Out, "No resources found\n") + } + return err +} diff --git a/pkg/cmd/clusterinfo/clusterinfo.go b/pkg/cmd/clusterinfo/clusterinfo.go new file mode 100644 index 000000000..742c3673a --- /dev/null +++ b/pkg/cmd/clusterinfo/clusterinfo.go @@ -0,0 +1,166 @@ +/* +Copyright 2015 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 clusterinfo + +import ( + "fmt" + "io" + "strconv" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilnet "k8s.io/apimachinery/pkg/util/net" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + restclient "k8s.io/client-go/rest" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + + ct "github.com/daviddengcn/go-colortext" + "github.com/spf13/cobra" +) + +var ( + longDescr = templates.LongDesc(i18n.T(` + Display addresses of the master and services with label kubernetes.io/cluster-service=true + To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.`)) + + clusterinfoExample = templates.Examples(i18n.T(` + # Print the address of the master and cluster services + kubectl cluster-info`)) +) + +type ClusterInfoOptions struct { + genericclioptions.IOStreams + + Namespace string + + Builder *resource.Builder + Client *restclient.Config +} + +func NewCmdClusterInfo(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := &ClusterInfoOptions{ + IOStreams: ioStreams, + } + + cmd := &cobra.Command{ + Use: "cluster-info", + Short: i18n.T("Display cluster info"), + Long: longDescr, + Example: clusterinfoExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(o.Run()) + }, + } + cmd.AddCommand(NewCmdClusterInfoDump(f, ioStreams)) + return cmd +} + +func (o *ClusterInfoOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + var err error + o.Client, err = f.ToRESTConfig() + if err != nil { + return err + } + + cmdNamespace := cmdutil.GetFlagString(cmd, "namespace") + if cmdNamespace == "" { + cmdNamespace = metav1.NamespaceSystem + } + o.Namespace = cmdNamespace + + o.Builder = f.NewBuilder() + return nil +} + +func (o *ClusterInfoOptions) Run() error { + // TODO use generalized labels once they are implemented (#341) + b := o.Builder. + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace(). + LabelSelectorParam("kubernetes.io/cluster-service=true"). + ResourceTypeOrNameArgs(false, []string{"services"}...). + Latest() + err := b.Do().Visit(func(r *resource.Info, err error) error { + if err != nil { + return err + } + printService(o.Out, "Kubernetes master", o.Client.Host) + + services := r.Object.(*corev1.ServiceList).Items + for _, service := range services { + var link string + if len(service.Status.LoadBalancer.Ingress) > 0 { + ingress := service.Status.LoadBalancer.Ingress[0] + ip := ingress.IP + if ip == "" { + ip = ingress.Hostname + } + for _, port := range service.Spec.Ports { + link += "http://" + ip + ":" + strconv.Itoa(int(port.Port)) + " " + } + } else { + name := service.ObjectMeta.Name + + if len(service.Spec.Ports) > 0 { + port := service.Spec.Ports[0] + + // guess if the scheme is https + scheme := "" + if port.Name == "https" || port.Port == 443 { + scheme = "https" + } + + // format is :: + name = utilnet.JoinSchemeNamePort(scheme, service.ObjectMeta.Name, port.Name) + } + + if len(o.Client.GroupVersion.Group) == 0 { + link = o.Client.Host + "/api/" + o.Client.GroupVersion.Version + "/namespaces/" + service.ObjectMeta.Namespace + "/services/" + name + "/proxy" + } else { + link = o.Client.Host + "/api/" + o.Client.GroupVersion.Group + "/" + o.Client.GroupVersion.Version + "/namespaces/" + service.ObjectMeta.Namespace + "/services/" + name + "/proxy" + + } + } + name := service.ObjectMeta.Labels["kubernetes.io/name"] + if len(name) == 0 { + name = service.ObjectMeta.Name + } + printService(o.Out, name, link) + } + return nil + }) + o.Out.Write([]byte("\nTo further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.\n")) + return err + + // TODO consider printing more information about cluster +} + +func printService(out io.Writer, name, link string) { + ct.ChangeColor(ct.Green, false, ct.None, false) + fmt.Fprint(out, name) + ct.ResetColor() + fmt.Fprint(out, " is running at ") + ct.ChangeColor(ct.Yellow, false, ct.None, false) + fmt.Fprint(out, link) + ct.ResetColor() + fmt.Fprintln(out, "") +} diff --git a/pkg/cmd/clusterinfo/clusterinfo_dump.go b/pkg/cmd/clusterinfo/clusterinfo_dump.go new file mode 100644 index 000000000..4c0e28220 --- /dev/null +++ b/pkg/cmd/clusterinfo/clusterinfo_dump.go @@ -0,0 +1,295 @@ +/* +Copyright 2016 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 clusterinfo + +import ( + "fmt" + "io" + "os" + "path" + "time" + + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + appsv1client "k8s.io/client-go/kubernetes/typed/apps/v1" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +const ( + defaultPodLogsTimeout = 20 * time.Second + timeout = 5 * time.Minute +) + +type ClusterInfoDumpOptions struct { + PrintFlags *genericclioptions.PrintFlags + PrintObj printers.ResourcePrinterFunc + + OutputDir string + AllNamespaces bool + Namespaces []string + + Timeout time.Duration + AppsClient appsv1client.AppsV1Interface + CoreClient corev1client.CoreV1Interface + Namespace string + RESTClientGetter genericclioptions.RESTClientGetter + LogsForObject polymorphichelpers.LogsForObjectFunc + + genericclioptions.IOStreams +} + +func NewCmdClusterInfoDump(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := &ClusterInfoDumpOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithTypeSetter(scheme.Scheme).WithDefaultOutput("json"), + + IOStreams: ioStreams, + } + + cmd := &cobra.Command{ + Use: "dump", + Short: i18n.T("Dump lots of relevant info for debugging and diagnosis"), + Long: dumpLong, + Example: dumpExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(o.Run()) + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().StringVar(&o.OutputDir, "output-directory", o.OutputDir, i18n.T("Where to output the files. If empty or '-' uses stdout, otherwise creates a directory hierarchy in that directory")) + cmd.Flags().StringSliceVar(&o.Namespaces, "namespaces", o.Namespaces, "A comma separated list of namespaces to dump.") + cmd.Flags().BoolVarP(&o.AllNamespaces, "all-namespaces", "A", o.AllNamespaces, "If true, dump all namespaces. If true, --namespaces is ignored.") + cmdutil.AddPodRunningTimeoutFlag(cmd, defaultPodLogsTimeout) + return cmd +} + +var ( + dumpLong = templates.LongDesc(i18n.T(` + Dumps cluster info out suitable for debugging and diagnosing cluster problems. By default, dumps everything to + stdout. You can optionally specify a directory with --output-directory. If you specify a directory, kubernetes will + build a set of files in that directory. By default only dumps things in the 'kube-system' namespace, but you can + switch to a different namespace with the --namespaces flag, or specify --all-namespaces to dump all namespaces. + + The command also dumps the logs of all of the pods in the cluster, these logs are dumped into different directories + based on namespace and pod name.`)) + + dumpExample = templates.Examples(i18n.T(` + # Dump current cluster state to stdout + kubectl cluster-info dump + + # Dump current cluster state to /path/to/cluster-state + kubectl cluster-info dump --output-directory=/path/to/cluster-state + + # Dump all namespaces to stdout + kubectl cluster-info dump --all-namespaces + + # Dump a set of namespaces to /path/to/cluster-state + kubectl cluster-info dump --namespaces default,kube-system --output-directory=/path/to/cluster-state`)) +) + +func setupOutputWriter(dir string, defaultWriter io.Writer, filename string) io.Writer { + if len(dir) == 0 || dir == "-" { + return defaultWriter + } + fullFile := path.Join(dir, filename) + parent := path.Dir(fullFile) + cmdutil.CheckErr(os.MkdirAll(parent, 0755)) + + file, err := os.Create(path.Join(dir, filename)) + cmdutil.CheckErr(err) + return file +} + +func (o *ClusterInfoDumpOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + + o.PrintObj = printer.PrintObj + + config, err := f.ToRESTConfig() + if err != nil { + return err + } + + o.CoreClient, err = corev1client.NewForConfig(config) + if err != nil { + return err + } + + o.AppsClient, err = appsv1client.NewForConfig(config) + if err != nil { + return err + } + + o.Timeout, err = cmdutil.GetPodRunningTimeoutFlag(cmd) + if err != nil { + return err + } + + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + // TODO this should eventually just be the completed kubeconfigflag struct + o.RESTClientGetter = f + o.LogsForObject = polymorphichelpers.LogsForObjectFn + + return nil +} + +func (o *ClusterInfoDumpOptions) Run() error { + nodes, err := o.CoreClient.Nodes().List(metav1.ListOptions{}) + if err != nil { + return err + } + + if err := o.PrintObj(nodes, setupOutputWriter(o.OutputDir, o.Out, "nodes.json")); err != nil { + return err + } + + var namespaces []string + if o.AllNamespaces { + namespaceList, err := o.CoreClient.Namespaces().List(metav1.ListOptions{}) + if err != nil { + return err + } + for ix := range namespaceList.Items { + namespaces = append(namespaces, namespaceList.Items[ix].Name) + } + } else { + if len(o.Namespaces) == 0 { + namespaces = []string{ + metav1.NamespaceSystem, + o.Namespace, + } + } + } + for _, namespace := range namespaces { + // TODO: this is repetitive in the extreme. Use reflection or + // something to make this a for loop. + events, err := o.CoreClient.Events(namespace).List(metav1.ListOptions{}) + if err != nil { + return err + } + if err := o.PrintObj(events, setupOutputWriter(o.OutputDir, o.Out, path.Join(namespace, "events.json"))); err != nil { + return err + } + + rcs, err := o.CoreClient.ReplicationControllers(namespace).List(metav1.ListOptions{}) + if err != nil { + return err + } + if err := o.PrintObj(rcs, setupOutputWriter(o.OutputDir, o.Out, path.Join(namespace, "replication-controllers.json"))); err != nil { + return err + } + + svcs, err := o.CoreClient.Services(namespace).List(metav1.ListOptions{}) + if err != nil { + return err + } + if err := o.PrintObj(svcs, setupOutputWriter(o.OutputDir, o.Out, path.Join(namespace, "services.json"))); err != nil { + return err + } + + sets, err := o.AppsClient.DaemonSets(namespace).List(metav1.ListOptions{}) + if err != nil { + return err + } + if err := o.PrintObj(sets, setupOutputWriter(o.OutputDir, o.Out, path.Join(namespace, "daemonsets.json"))); err != nil { + return err + } + + deps, err := o.AppsClient.Deployments(namespace).List(metav1.ListOptions{}) + if err != nil { + return err + } + if err := o.PrintObj(deps, setupOutputWriter(o.OutputDir, o.Out, path.Join(namespace, "deployments.json"))); err != nil { + return err + } + + rps, err := o.AppsClient.ReplicaSets(namespace).List(metav1.ListOptions{}) + if err != nil { + return err + } + if err := o.PrintObj(rps, setupOutputWriter(o.OutputDir, o.Out, path.Join(namespace, "replicasets.json"))); err != nil { + return err + } + + pods, err := o.CoreClient.Pods(namespace).List(metav1.ListOptions{}) + if err != nil { + return err + } + + if err := o.PrintObj(pods, setupOutputWriter(o.OutputDir, o.Out, path.Join(namespace, "pods.json"))); err != nil { + return err + } + + printContainer := func(writer io.Writer, container corev1.Container, pod *corev1.Pod) { + writer.Write([]byte(fmt.Sprintf("==== START logs for container %s of pod %s/%s ====\n", container.Name, pod.Namespace, pod.Name))) + defer writer.Write([]byte(fmt.Sprintf("==== END logs for container %s of pod %s/%s ====\n", container.Name, pod.Namespace, pod.Name))) + + requests, err := o.LogsForObject(o.RESTClientGetter, pod, &corev1.PodLogOptions{Container: container.Name}, timeout, false) + if err != nil { + // Print error and return. + writer.Write([]byte(fmt.Sprintf("Create log request error: %s\n", err.Error()))) + return + } + + for _, request := range requests { + data, err := request.DoRaw() + if err != nil { + // Print error and return. + writer.Write([]byte(fmt.Sprintf("Request log error: %s\n", err.Error()))) + return + } + writer.Write(data) + } + } + + for ix := range pods.Items { + pod := &pods.Items[ix] + containers := pod.Spec.Containers + writer := setupOutputWriter(o.OutputDir, o.Out, path.Join(namespace, pod.Name, "logs.txt")) + + for i := range containers { + printContainer(writer, containers[i], pod) + } + } + } + + dest := o.OutputDir + if len(dest) == 0 { + dest = "standard output" + } + if dest != "-" { + fmt.Fprintf(o.Out, "Cluster info dumped to %s\n", dest) + } + return nil +} diff --git a/pkg/cmd/clusterinfo/clusterinfo_dump_test.go b/pkg/cmd/clusterinfo/clusterinfo_dump_test.go new file mode 100644 index 000000000..bbe62f0be --- /dev/null +++ b/pkg/cmd/clusterinfo/clusterinfo_dump_test.go @@ -0,0 +1,70 @@ +/* +Copyright 2016 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 clusterinfo + +import ( + "io/ioutil" + "os" + "path" + "testing" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestSetupOutputWriterNoOp(t *testing.T) { + tests := []string{"", "-"} + for _, test := range tests { + _, _, buf, _ := genericclioptions.NewTestIOStreams() + f := cmdtesting.NewTestFactory() + defer f.Cleanup() + + writer := setupOutputWriter(test, buf, "/some/file/that/should/be/ignored") + if writer != buf { + t.Errorf("expected: %v, saw: %v", buf, writer) + } + } +} + +func TestSetupOutputWriterFile(t *testing.T) { + file := "output.json" + dir, err := ioutil.TempDir(os.TempDir(), "out") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + fullPath := path.Join(dir, file) + defer os.RemoveAll(dir) + + _, _, buf, _ := genericclioptions.NewTestIOStreams() + f := cmdtesting.NewTestFactory() + defer f.Cleanup() + + writer := setupOutputWriter(dir, buf, file) + if writer == buf { + t.Errorf("expected: %v, saw: %v", buf, writer) + } + output := "some data here" + writer.Write([]byte(output)) + + data, err := ioutil.ReadFile(fullPath) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if string(data) != output { + t.Errorf("expected: %v, saw: %v", output, data) + } +} diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go new file mode 100644 index 000000000..1acbdb8e7 --- /dev/null +++ b/pkg/cmd/completion/completion.go @@ -0,0 +1,314 @@ +/* +Copyright 2016 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 completion + +import ( + "bytes" + "io" + + "github.com/spf13/cobra" + + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +const defaultBoilerPlate = ` +# Copyright 2016 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. +` + +var ( + completionLong = templates.LongDesc(i18n.T(` + Output shell completion code for the specified shell (bash or zsh). + The shell code must be evaluated to provide interactive + completion of kubectl commands. This can be done by sourcing it from + the .bash_profile. + + Detailed instructions on how to do this are available here: + https://kubernetes.io/docs/tasks/tools/install-kubectl/#enabling-shell-autocompletion + + Note for zsh users: [1] zsh completions are only supported in versions of zsh >= 5.2`)) + + completionExample = templates.Examples(i18n.T(` + # Installing bash completion on macOS using homebrew + ## If running Bash 3.2 included with macOS + brew install bash-completion + ## or, if running Bash 4.1+ + brew install bash-completion@2 + ## If kubectl is installed via homebrew, this should start working immediately. + ## If you've installed via other means, you may need add the completion to your completion directory + kubectl completion bash > $(brew --prefix)/etc/bash_completion.d/kubectl + + + # Installing bash completion on Linux + ## If bash-completion is not installed on Linux, please install the 'bash-completion' package + ## via your distribution's package manager. + ## Load the kubectl completion code for bash into the current shell + source <(kubectl completion bash) + ## Write bash completion code to a file and source if from .bash_profile + kubectl completion bash > ~/.kube/completion.bash.inc + printf " + # Kubectl shell completion + source '$HOME/.kube/completion.bash.inc' + " >> $HOME/.bash_profile + source $HOME/.bash_profile + + # Load the kubectl completion code for zsh[1] into the current shell + source <(kubectl completion zsh) + # Set the kubectl completion code for zsh[1] to autoload on startup + kubectl completion zsh > "${fpath[1]}/_kubectl"`)) +) + +var ( + completionShells = map[string]func(out io.Writer, boilerPlate string, cmd *cobra.Command) error{ + "bash": runCompletionBash, + "zsh": runCompletionZsh, + } +) + +// NewCmdCompletion creates the `completion` command +func NewCmdCompletion(out io.Writer, boilerPlate string) *cobra.Command { + shells := []string{} + for s := range completionShells { + shells = append(shells, s) + } + + cmd := &cobra.Command{ + Use: "completion SHELL", + DisableFlagsInUseLine: true, + Short: i18n.T("Output shell completion code for the specified shell (bash or zsh)"), + Long: completionLong, + Example: completionExample, + Run: func(cmd *cobra.Command, args []string) { + err := RunCompletion(out, boilerPlate, cmd, args) + cmdutil.CheckErr(err) + }, + ValidArgs: shells, + } + + return cmd +} + +// RunCompletion checks given arguments and executes command +func RunCompletion(out io.Writer, boilerPlate string, cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmdutil.UsageErrorf(cmd, "Shell not specified.") + } + if len(args) > 1 { + return cmdutil.UsageErrorf(cmd, "Too many arguments. Expected only the shell type.") + } + run, found := completionShells[args[0]] + if !found { + return cmdutil.UsageErrorf(cmd, "Unsupported shell type %q.", args[0]) + } + + return run(out, boilerPlate, cmd.Parent()) +} + +func runCompletionBash(out io.Writer, boilerPlate string, kubectl *cobra.Command) error { + if len(boilerPlate) == 0 { + boilerPlate = defaultBoilerPlate + } + if _, err := out.Write([]byte(boilerPlate)); err != nil { + return err + } + + return kubectl.GenBashCompletion(out) +} + +func runCompletionZsh(out io.Writer, boilerPlate string, kubectl *cobra.Command) error { + zshHead := "#compdef kubectl\n" + + out.Write([]byte(zshHead)) + + if len(boilerPlate) == 0 { + boilerPlate = defaultBoilerPlate + } + if _, err := out.Write([]byte(boilerPlate)); err != nil { + return err + } + + zshInitialization := ` +__kubectl_bash_source() { + alias shopt=':' + alias _expand=_bash_expand + alias _complete=_bash_comp + emulate -L sh + setopt kshglob noshglob braceexpand + + source "$@" +} + +__kubectl_type() { + # -t is not supported by zsh + if [ "$1" == "-t" ]; then + shift + + # fake Bash 4 to disable "complete -o nospace". Instead + # "compopt +-o nospace" is used in the code to toggle trailing + # spaces. We don't support that, but leave trailing spaces on + # all the time + if [ "$1" = "__kubectl_compopt" ]; then + echo builtin + return 0 + fi + fi + type "$@" +} + +__kubectl_compgen() { + local completions w + completions=( $(compgen "$@") ) || return $? + + # filter by given word as prefix + while [[ "$1" = -* && "$1" != -- ]]; do + shift + shift + done + if [[ "$1" == -- ]]; then + shift + fi + for w in "${completions[@]}"; do + if [[ "${w}" = "$1"* ]]; then + echo "${w}" + fi + done +} + +__kubectl_compopt() { + true # don't do anything. Not supported by bashcompinit in zsh +} + +__kubectl_ltrim_colon_completions() +{ + if [[ "$1" == *:* && "$COMP_WORDBREAKS" == *:* ]]; then + # Remove colon-word prefix from COMPREPLY items + local colon_word=${1%${1##*:}} + local i=${#COMPREPLY[*]} + while [[ $((--i)) -ge 0 ]]; do + COMPREPLY[$i]=${COMPREPLY[$i]#"$colon_word"} + done + fi +} + +__kubectl_get_comp_words_by_ref() { + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[${COMP_CWORD}-1]}" + words=("${COMP_WORDS[@]}") + cword=("${COMP_CWORD[@]}") +} + +__kubectl_filedir() { + local RET OLD_IFS w qw + + __kubectl_debug "_filedir $@ cur=$cur" + if [[ "$1" = \~* ]]; then + # somehow does not work. Maybe, zsh does not call this at all + eval echo "$1" + return 0 + fi + + OLD_IFS="$IFS" + IFS=$'\n' + if [ "$1" = "-d" ]; then + shift + RET=( $(compgen -d) ) + else + RET=( $(compgen -f) ) + fi + IFS="$OLD_IFS" + + IFS="," __kubectl_debug "RET=${RET[@]} len=${#RET[@]}" + + for w in ${RET[@]}; do + if [[ ! "${w}" = "${cur}"* ]]; then + continue + fi + if eval "[[ \"\${w}\" = *.$1 || -d \"\${w}\" ]]"; then + qw="$(__kubectl_quote "${w}")" + if [ -d "${w}" ]; then + COMPREPLY+=("${qw}/") + else + COMPREPLY+=("${qw}") + fi + fi + done +} + +__kubectl_quote() { + if [[ $1 == \'* || $1 == \"* ]]; then + # Leave out first character + printf %q "${1:1}" + else + printf %q "$1" + fi +} + +autoload -U +X bashcompinit && bashcompinit + +# use word boundary patterns for BSD or GNU sed +LWORD='[[:<:]]' +RWORD='[[:>:]]' +if sed --help 2>&1 | grep -q GNU; then + LWORD='\<' + RWORD='\>' +fi + +__kubectl_convert_bash_to_zsh() { + sed \ + -e 's/declare -F/whence -w/' \ + -e 's/_get_comp_words_by_ref "\$@"/_get_comp_words_by_ref "\$*"/' \ + -e 's/local \([a-zA-Z0-9_]*\)=/local \1; \1=/' \ + -e 's/flags+=("\(--.*\)=")/flags+=("\1"); two_word_flags+=("\1")/' \ + -e 's/must_have_one_flag+=("\(--.*\)=")/must_have_one_flag+=("\1")/' \ + -e "s/${LWORD}_filedir${RWORD}/__kubectl_filedir/g" \ + -e "s/${LWORD}_get_comp_words_by_ref${RWORD}/__kubectl_get_comp_words_by_ref/g" \ + -e "s/${LWORD}__ltrim_colon_completions${RWORD}/__kubectl_ltrim_colon_completions/g" \ + -e "s/${LWORD}compgen${RWORD}/__kubectl_compgen/g" \ + -e "s/${LWORD}compopt${RWORD}/__kubectl_compopt/g" \ + -e "s/${LWORD}declare${RWORD}/builtin declare/g" \ + -e "s/\\\$(type${RWORD}/\$(__kubectl_type/g" \ + <<'BASH_COMPLETION_EOF' +` + out.Write([]byte(zshInitialization)) + + buf := new(bytes.Buffer) + kubectl.GenBashCompletion(buf) + out.Write(buf.Bytes()) + + zshTail := ` +BASH_COMPLETION_EOF +} + +__kubectl_bash_source <(__kubectl_convert_bash_to_zsh) +_complete kubectl 2>/dev/null +` + out.Write([]byte(zshTail)) + return nil +} diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go new file mode 100644 index 000000000..8ec652944 --- /dev/null +++ b/pkg/cmd/config/config.go @@ -0,0 +1,92 @@ +/* +Copyright 2014 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 config + +import ( + "fmt" + "path" + "strconv" + + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/tools/clientcmd" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// NewCmdConfig creates a command object for the "config" action, and adds all child commands to it. +func NewCmdConfig(f cmdutil.Factory, pathOptions *clientcmd.PathOptions, streams genericclioptions.IOStreams) *cobra.Command { + if len(pathOptions.ExplicitFileFlag) == 0 { + pathOptions.ExplicitFileFlag = clientcmd.RecommendedConfigPathFlag + } + + cmd := &cobra.Command{ + Use: "config SUBCOMMAND", + DisableFlagsInUseLine: true, + Short: i18n.T("Modify kubeconfig files"), + Long: templates.LongDesc(` + Modify kubeconfig files using subcommands like "kubectl config set current-context my-context" + + The loading order follows these rules: + + 1. If the --` + pathOptions.ExplicitFileFlag + ` flag is set, then only that file is loaded. The flag may only be set once and no merging takes place. + 2. If $` + pathOptions.EnvVar + ` environment variable is set, then it is used as a list of paths (normal path delimiting rules for your system). These paths are merged. When a value is modified, it is modified in the file that defines the stanza. When a value is created, it is created in the first file that exists. If no files in the chain exist, then it creates the last file in the list. + 3. Otherwise, ` + path.Join("${HOME}", pathOptions.GlobalFileSubpath) + ` is used and no merging takes place.`), + Run: cmdutil.DefaultSubCommandRun(streams.ErrOut), + } + + // file paths are common to all sub commands + cmd.PersistentFlags().StringVar(&pathOptions.LoadingRules.ExplicitPath, pathOptions.ExplicitFileFlag, pathOptions.LoadingRules.ExplicitPath, "use a particular kubeconfig file") + + // TODO(juanvallejo): update all subcommands to work with genericclioptions.IOStreams + cmd.AddCommand(NewCmdConfigView(f, streams, pathOptions)) + cmd.AddCommand(NewCmdConfigSetCluster(streams.Out, pathOptions)) + cmd.AddCommand(NewCmdConfigSetAuthInfo(streams.Out, pathOptions)) + cmd.AddCommand(NewCmdConfigSetContext(streams.Out, pathOptions)) + cmd.AddCommand(NewCmdConfigSet(streams.Out, pathOptions)) + cmd.AddCommand(NewCmdConfigUnset(streams.Out, pathOptions)) + cmd.AddCommand(NewCmdConfigCurrentContext(streams.Out, pathOptions)) + cmd.AddCommand(NewCmdConfigUseContext(streams.Out, pathOptions)) + cmd.AddCommand(NewCmdConfigGetContexts(streams, pathOptions)) + cmd.AddCommand(NewCmdConfigGetClusters(streams.Out, pathOptions)) + cmd.AddCommand(NewCmdConfigDeleteCluster(streams.Out, pathOptions)) + cmd.AddCommand(NewCmdConfigDeleteContext(streams.Out, streams.ErrOut, pathOptions)) + cmd.AddCommand(NewCmdConfigRenameContext(streams.Out, pathOptions)) + + return cmd +} + +func toBool(propertyValue string) (bool, error) { + boolValue := false + if len(propertyValue) != 0 { + var err error + boolValue, err = strconv.ParseBool(propertyValue) + if err != nil { + return false, err + } + } + + return boolValue, nil +} + +func helpErrorf(cmd *cobra.Command, format string, args ...interface{}) error { + cmd.Help() + msg := fmt.Sprintf(format, args...) + return fmt.Errorf("%s", msg) +} diff --git a/pkg/cmd/config/config_test.go b/pkg/cmd/config/config_test.go new file mode 100644 index 000000000..df9f1c3af --- /dev/null +++ b/pkg/cmd/config/config_test.go @@ -0,0 +1,949 @@ +/* +Copyright 2014 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 config + +import ( + "fmt" + "io/ioutil" + "os" + "path" + "reflect" + "strings" + "testing" + + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/util/diff" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +func newRedFederalCowHammerConfig() clientcmdapi.Config { + return clientcmdapi.Config{ + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "red-user": {Token: "red-token"}}, + Clusters: map[string]*clientcmdapi.Cluster{ + "cow-cluster": {Server: "http://cow.org:8080"}}, + Contexts: map[string]*clientcmdapi.Context{ + "federal-context": {AuthInfo: "red-user", Cluster: "cow-cluster"}}, + CurrentContext: "federal-context", + } +} + +func Example_view() { + expectedConfig := newRedFederalCowHammerConfig() + test := configCommandTest{ + args: []string{"view"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + output := test.run(nil) + fmt.Printf("%v", output) + // Output: + // apiVersion: v1 + // clusters: + // - cluster: + // server: http://cow.org:8080 + // name: cow-cluster + // contexts: + // - context: + // cluster: cow-cluster + // user: red-user + // name: federal-context + // current-context: federal-context + // kind: Config + // preferences: {} + // users: + // - name: red-user + // user: + // token: red-token +} + +func TestCurrentContext(t *testing.T) { + startingConfig := newRedFederalCowHammerConfig() + test := configCommandTest{ + args: []string{"current-context"}, + startingConfig: startingConfig, + expectedConfig: startingConfig, + expectedOutputs: []string{startingConfig.CurrentContext}, + } + test.run(t) +} + +func TestSetCurrentContext(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + startingConfig := newRedFederalCowHammerConfig() + + newContextName := "the-new-context" + + startingConfig.Contexts[newContextName] = clientcmdapi.NewContext() + expectedConfig.Contexts[newContextName] = clientcmdapi.NewContext() + + expectedConfig.CurrentContext = newContextName + + test := configCommandTest{ + args: []string{"use-context", "the-new-context"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestSetNonExistentContext(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + + test := configCommandTest{ + args: []string{"use-context", "non-existent-config"}, + startingConfig: expectedConfig, + expectedConfig: expectedConfig, + } + + func() { + defer func() { + // Restore cmdutil behavior. + cmdutil.DefaultBehaviorOnFatal() + }() + + // Check exit code. + cmdutil.BehaviorOnFatal(func(e string, code int) { + if code != 1 { + t.Errorf("The exit code is %d, expected 1", code) + } + expectedOutputs := []string{`no context exists with the name: "non-existent-config"`} + test.checkOutput(e, expectedOutputs, t) + }) + + test.run(t) + }() +} + +func TestSetIntoExistingStruct(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.AuthInfos["red-user"].Password = "new-path-value" + test := configCommandTest{ + args: []string{"set", "users.red-user.password", "new-path-value"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestSetWithPathPrefixIntoExistingStruct(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.Clusters["cow-cluster"].Server = "http://cow.org:8080/foo/baz" + test := configCommandTest{ + args: []string{"set", "clusters.cow-cluster.server", "http://cow.org:8080/foo/baz"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) + + dc := clientcmd.NewDefaultClientConfig(expectedConfig, &clientcmd.ConfigOverrides{}) + dcc, err := dc.ClientConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedHost := "http://cow.org:8080/foo/baz" + if expectedHost != dcc.Host { + t.Fatalf("expected client.Config.Host = %q instead of %q", expectedHost, dcc.Host) + } +} + +func TestUnsetStruct(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + delete(expectedConfig.AuthInfos, "red-user") + test := configCommandTest{ + args: []string{"unset", "users.red-user"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestUnsetField(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.AuthInfos["red-user"] = clientcmdapi.NewAuthInfo() + test := configCommandTest{ + args: []string{"unset", "users.red-user.token"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestSetIntoNewStruct(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + cluster := clientcmdapi.NewCluster() + cluster.Server = "new-server-value" + expectedConfig.Clusters["big-cluster"] = cluster + test := configCommandTest{ + args: []string{"set", "clusters.big-cluster.server", "new-server-value"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestSetBoolean(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + cluster := clientcmdapi.NewCluster() + cluster.InsecureSkipTLSVerify = true + expectedConfig.Clusters["big-cluster"] = cluster + test := configCommandTest{ + args: []string{"set", "clusters.big-cluster.insecure-skip-tls-verify", "true"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestSetIntoNewConfig(t *testing.T) { + expectedConfig := *clientcmdapi.NewConfig() + context := clientcmdapi.NewContext() + context.AuthInfo = "fake-user" + expectedConfig.Contexts["new-context"] = context + test := configCommandTest{ + args: []string{"set", "contexts.new-context.user", "fake-user"}, + startingConfig: *clientcmdapi.NewConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestNewEmptyAuth(t *testing.T) { + expectedConfig := *clientcmdapi.NewConfig() + expectedConfig.AuthInfos["the-user-name"] = clientcmdapi.NewAuthInfo() + test := configCommandTest{ + args: []string{"set-credentials", "the-user-name"}, + startingConfig: *clientcmdapi.NewConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestAdditionalAuth(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + authInfo := clientcmdapi.NewAuthInfo() + authInfo.Token = "token" + expectedConfig.AuthInfos["another-user"] = authInfo + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagBearerToken + "=token"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestEmbedClientCert(t *testing.T) { + fakeCertFile, _ := ioutil.TempFile("", "") + defer os.Remove(fakeCertFile.Name()) + fakeData := []byte("fake-data") + ioutil.WriteFile(fakeCertFile.Name(), fakeData, 0600) + expectedConfig := newRedFederalCowHammerConfig() + authInfo := clientcmdapi.NewAuthInfo() + authInfo.ClientCertificateData = fakeData + expectedConfig.AuthInfos["another-user"] = authInfo + + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagCertFile + "=" + fakeCertFile.Name(), "--" + clientcmd.FlagEmbedCerts + "=true"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestEmbedClientKey(t *testing.T) { + fakeKeyFile, _ := ioutil.TempFile("", "") + defer os.Remove(fakeKeyFile.Name()) + fakeData := []byte("fake-data") + ioutil.WriteFile(fakeKeyFile.Name(), fakeData, 0600) + expectedConfig := newRedFederalCowHammerConfig() + authInfo := clientcmdapi.NewAuthInfo() + authInfo.ClientKeyData = fakeData + expectedConfig.AuthInfos["another-user"] = authInfo + + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagKeyFile + "=" + fakeKeyFile.Name(), "--" + clientcmd.FlagEmbedCerts + "=true"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestEmbedNoKeyOrCertDisallowed(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagEmbedCerts + "=true"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + func() { + defer func() { + // Restore cmdutil behavior. + cmdutil.DefaultBehaviorOnFatal() + }() + + // Check exit code. + cmdutil.BehaviorOnFatal(func(e string, code int) { + if code != 1 { + t.Errorf("The exit code is %d, expected 1", code) + } + expectedOutputs := []string{"--client-certificate", "--client-key", "embed"} + test.checkOutput(e, expectedOutputs, t) + }) + + test.run(t) + }() +} + +func TestEmptyTokenAndCertAllowed(t *testing.T) { + fakeCertFile, _ := ioutil.TempFile("", "cert-file") + defer os.Remove(fakeCertFile.Name()) + expectedConfig := newRedFederalCowHammerConfig() + authInfo := clientcmdapi.NewAuthInfo() + authInfo.ClientCertificate = path.Base(fakeCertFile.Name()) + expectedConfig.AuthInfos["another-user"] = authInfo + + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagCertFile + "=" + fakeCertFile.Name(), "--" + clientcmd.FlagBearerToken + "="}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestTokenAndCertAllowed(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + authInfo := clientcmdapi.NewAuthInfo() + authInfo.Token = "token" + authInfo.ClientCertificate = "/cert-file" + expectedConfig.AuthInfos["another-user"] = authInfo + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagCertFile + "=/cert-file", "--" + clientcmd.FlagBearerToken + "=token"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestTokenAndBasicDisallowed(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagUsername + "=myuser", "--" + clientcmd.FlagBearerToken + "=token"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + func() { + defer func() { + // Restore cmdutil behavior. + cmdutil.DefaultBehaviorOnFatal() + }() + + // Check exit code. + cmdutil.BehaviorOnFatal(func(e string, code int) { + if code != 1 { + t.Errorf("The exit code is %d, expected 1", code) + } + + expectedOutputs := []string{"--token", "--username"} + test.checkOutput(e, expectedOutputs, t) + }) + + test.run(t) + }() +} + +func TestBasicClearsToken(t *testing.T) { + authInfoWithToken := clientcmdapi.NewAuthInfo() + authInfoWithToken.Token = "token" + + authInfoWithBasic := clientcmdapi.NewAuthInfo() + authInfoWithBasic.Username = "myuser" + authInfoWithBasic.Password = "mypass" + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.AuthInfos["another-user"] = authInfoWithToken + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.AuthInfos["another-user"] = authInfoWithBasic + + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagUsername + "=myuser", "--" + clientcmd.FlagPassword + "=mypass"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestTokenClearsBasic(t *testing.T) { + authInfoWithBasic := clientcmdapi.NewAuthInfo() + authInfoWithBasic.Username = "myuser" + authInfoWithBasic.Password = "mypass" + + authInfoWithToken := clientcmdapi.NewAuthInfo() + authInfoWithToken.Token = "token" + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.AuthInfos["another-user"] = authInfoWithBasic + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.AuthInfos["another-user"] = authInfoWithToken + + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagBearerToken + "=token"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestTokenLeavesCert(t *testing.T) { + authInfoWithCerts := clientcmdapi.NewAuthInfo() + authInfoWithCerts.ClientCertificate = "cert" + authInfoWithCerts.ClientCertificateData = []byte("certdata") + authInfoWithCerts.ClientKey = "key" + authInfoWithCerts.ClientKeyData = []byte("keydata") + + authInfoWithTokenAndCerts := clientcmdapi.NewAuthInfo() + authInfoWithTokenAndCerts.Token = "token" + authInfoWithTokenAndCerts.ClientCertificate = "cert" + authInfoWithTokenAndCerts.ClientCertificateData = []byte("certdata") + authInfoWithTokenAndCerts.ClientKey = "key" + authInfoWithTokenAndCerts.ClientKeyData = []byte("keydata") + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.AuthInfos["another-user"] = authInfoWithCerts + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.AuthInfos["another-user"] = authInfoWithTokenAndCerts + + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagBearerToken + "=token"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestCertLeavesToken(t *testing.T) { + authInfoWithToken := clientcmdapi.NewAuthInfo() + authInfoWithToken.Token = "token" + + authInfoWithTokenAndCerts := clientcmdapi.NewAuthInfo() + authInfoWithTokenAndCerts.Token = "token" + authInfoWithTokenAndCerts.ClientCertificate = "/cert" + authInfoWithTokenAndCerts.ClientKey = "/key" + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.AuthInfos["another-user"] = authInfoWithToken + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.AuthInfos["another-user"] = authInfoWithTokenAndCerts + + test := configCommandTest{ + args: []string{"set-credentials", "another-user", "--" + clientcmd.FlagCertFile + "=/cert", "--" + clientcmd.FlagKeyFile + "=/key"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestSetBytesBad(t *testing.T) { + startingConfig := newRedFederalCowHammerConfig() + startingConfig.Clusters["another-cluster"] = clientcmdapi.NewCluster() + + test := configCommandTest{ + args: []string{"set", "clusters.another-cluster.certificate-authority-data", "cadata"}, + startingConfig: startingConfig, + expectedConfig: startingConfig, + } + + func() { + defer func() { + // Restore cmdutil behavior. + cmdutil.DefaultBehaviorOnFatal() + }() + + // Check exit code. + cmdutil.BehaviorOnFatal(func(e string, code int) { + if code != 1 { + t.Errorf("The exit code is %d, expected 1", code) + } + }) + + test.run(t) + }() +} + +func TestSetBytes(t *testing.T) { + clusterInfoWithCAData := clientcmdapi.NewCluster() + clusterInfoWithCAData.CertificateAuthorityData = []byte("cadata") + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.Clusters["another-cluster"] = clientcmdapi.NewCluster() + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.Clusters["another-cluster"] = clusterInfoWithCAData + + test := configCommandTest{ + args: []string{"set", "clusters.another-cluster.certificate-authority-data", "cadata", "--set-raw-bytes"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestSetBase64Bytes(t *testing.T) { + clusterInfoWithCAData := clientcmdapi.NewCluster() + clusterInfoWithCAData.CertificateAuthorityData = []byte("cadata") + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.Clusters["another-cluster"] = clientcmdapi.NewCluster() + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.Clusters["another-cluster"] = clusterInfoWithCAData + + test := configCommandTest{ + args: []string{"set", "clusters.another-cluster.certificate-authority-data", "Y2FkYXRh"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestUnsetBytes(t *testing.T) { + clusterInfoWithCAData := clientcmdapi.NewCluster() + clusterInfoWithCAData.CertificateAuthorityData = []byte("cadata") + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.Clusters["another-cluster"] = clusterInfoWithCAData + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.Clusters["another-cluster"] = clientcmdapi.NewCluster() + + test := configCommandTest{ + args: []string{"unset", "clusters.another-cluster.certificate-authority-data"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestCAClearsInsecure(t *testing.T) { + fakeCAFile, _ := ioutil.TempFile("", "ca-file") + defer os.Remove(fakeCAFile.Name()) + clusterInfoWithInsecure := clientcmdapi.NewCluster() + clusterInfoWithInsecure.InsecureSkipTLSVerify = true + + clusterInfoWithCA := clientcmdapi.NewCluster() + clusterInfoWithCA.CertificateAuthority = path.Base(fakeCAFile.Name()) + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.Clusters["another-cluster"] = clusterInfoWithInsecure + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.Clusters["another-cluster"] = clusterInfoWithCA + + test := configCommandTest{ + args: []string{"set-cluster", "another-cluster", "--" + clientcmd.FlagCAFile + "=" + fakeCAFile.Name()}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestCAClearsCAData(t *testing.T) { + clusterInfoWithCAData := clientcmdapi.NewCluster() + clusterInfoWithCAData.CertificateAuthorityData = []byte("cadata") + + clusterInfoWithCA := clientcmdapi.NewCluster() + clusterInfoWithCA.CertificateAuthority = "/cafile" + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.Clusters["another-cluster"] = clusterInfoWithCAData + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.Clusters["another-cluster"] = clusterInfoWithCA + + test := configCommandTest{ + args: []string{"set-cluster", "another-cluster", "--" + clientcmd.FlagCAFile + "=/cafile", "--" + clientcmd.FlagInsecure + "=false"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestInsecureClearsCA(t *testing.T) { + clusterInfoWithInsecure := clientcmdapi.NewCluster() + clusterInfoWithInsecure.InsecureSkipTLSVerify = true + + clusterInfoWithCA := clientcmdapi.NewCluster() + clusterInfoWithCA.CertificateAuthority = "cafile" + clusterInfoWithCA.CertificateAuthorityData = []byte("cadata") + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.Clusters["another-cluster"] = clusterInfoWithCA + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.Clusters["another-cluster"] = clusterInfoWithInsecure + + test := configCommandTest{ + args: []string{"set-cluster", "another-cluster", "--" + clientcmd.FlagInsecure + "=true"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestCADataClearsCA(t *testing.T) { + fakeCAFile, _ := ioutil.TempFile("", "") + defer os.Remove(fakeCAFile.Name()) + fakeData := []byte("cadata") + ioutil.WriteFile(fakeCAFile.Name(), fakeData, 0600) + + clusterInfoWithCAData := clientcmdapi.NewCluster() + clusterInfoWithCAData.CertificateAuthorityData = fakeData + + clusterInfoWithCA := clientcmdapi.NewCluster() + clusterInfoWithCA.CertificateAuthority = "cafile" + + startingConfig := newRedFederalCowHammerConfig() + startingConfig.Clusters["another-cluster"] = clusterInfoWithCA + + expectedConfig := newRedFederalCowHammerConfig() + expectedConfig.Clusters["another-cluster"] = clusterInfoWithCAData + + test := configCommandTest{ + args: []string{"set-cluster", "another-cluster", "--" + clientcmd.FlagCAFile + "=" + fakeCAFile.Name(), "--" + clientcmd.FlagEmbedCerts + "=true"}, + startingConfig: startingConfig, + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestEmbedNoCADisallowed(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + test := configCommandTest{ + args: []string{"set-cluster", "another-cluster", "--" + clientcmd.FlagEmbedCerts + "=true"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + func() { + defer func() { + // Restore cmdutil behavior. + cmdutil.DefaultBehaviorOnFatal() + }() + + // Check exit code. + cmdutil.BehaviorOnFatal(func(e string, code int) { + if code != 1 { + t.Errorf("The exit code is %d, expected 1", code) + } + + expectedOutputs := []string{"--certificate-authority", "embed"} + test.checkOutput(e, expectedOutputs, t) + }) + + test.run(t) + }() +} + +func TestCAAndInsecureDisallowed(t *testing.T) { + test := configCommandTest{ + args: []string{"set-cluster", "another-cluster", "--" + clientcmd.FlagCAFile + "=cafile", "--" + clientcmd.FlagInsecure + "=true"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: newRedFederalCowHammerConfig(), + } + + func() { + defer func() { + // Restore cmdutil behavior. + cmdutil.DefaultBehaviorOnFatal() + }() + + // Check exit code. + cmdutil.BehaviorOnFatal(func(e string, code int) { + if code != 1 { + t.Errorf("The exit code is %d, expected 1", code) + } + + expectedOutputs := []string{"certificate", "insecure"} + test.checkOutput(e, expectedOutputs, t) + }) + + test.run(t) + }() +} + +func TestMergeExistingAuth(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + authInfo := expectedConfig.AuthInfos["red-user"] + authInfo.ClientKey = "/key" + expectedConfig.AuthInfos["red-user"] = authInfo + test := configCommandTest{ + args: []string{"set-credentials", "red-user", "--" + clientcmd.FlagKeyFile + "=/key"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestNewEmptyCluster(t *testing.T) { + expectedConfig := *clientcmdapi.NewConfig() + expectedConfig.Clusters["new-cluster"] = clientcmdapi.NewCluster() + test := configCommandTest{ + args: []string{"set-cluster", "new-cluster"}, + startingConfig: *clientcmdapi.NewConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestAdditionalCluster(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + cluster := clientcmdapi.NewCluster() + cluster.CertificateAuthority = "/ca-location" + cluster.InsecureSkipTLSVerify = false + cluster.Server = "serverlocation" + expectedConfig.Clusters["different-cluster"] = cluster + test := configCommandTest{ + args: []string{"set-cluster", "different-cluster", "--" + clientcmd.FlagAPIServer + "=serverlocation", "--" + clientcmd.FlagInsecure + "=false", "--" + clientcmd.FlagCAFile + "=/ca-location"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestOverwriteExistingCluster(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + cluster := clientcmdapi.NewCluster() + cluster.Server = "serverlocation" + expectedConfig.Clusters["cow-cluster"] = cluster + + test := configCommandTest{ + args: []string{"set-cluster", "cow-cluster", "--" + clientcmd.FlagAPIServer + "=serverlocation"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestNewEmptyContext(t *testing.T) { + expectedConfig := *clientcmdapi.NewConfig() + expectedConfig.Contexts["new-context"] = clientcmdapi.NewContext() + test := configCommandTest{ + args: []string{"set-context", "new-context"}, + startingConfig: *clientcmdapi.NewConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestAdditionalContext(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + context := clientcmdapi.NewContext() + context.Cluster = "some-cluster" + context.AuthInfo = "some-user" + context.Namespace = "different-namespace" + expectedConfig.Contexts["different-context"] = context + test := configCommandTest{ + args: []string{"set-context", "different-context", "--" + clientcmd.FlagClusterName + "=some-cluster", "--" + clientcmd.FlagAuthInfoName + "=some-user", "--" + clientcmd.FlagNamespace + "=different-namespace"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestMergeExistingContext(t *testing.T) { + expectedConfig := newRedFederalCowHammerConfig() + context := expectedConfig.Contexts["federal-context"] + context.Namespace = "hammer" + expectedConfig.Contexts["federal-context"] = context + + test := configCommandTest{ + args: []string{"set-context", "federal-context", "--" + clientcmd.FlagNamespace + "=hammer"}, + startingConfig: newRedFederalCowHammerConfig(), + expectedConfig: expectedConfig, + } + + test.run(t) +} + +func TestToBool(t *testing.T) { + type test struct { + in string + out bool + err string + } + + tests := []test{ + {"", false, ""}, + {"true", true, ""}, + {"on", false, `strconv.ParseBool: parsing "on": invalid syntax`}, + } + + for _, curr := range tests { + b, err := toBool(curr.in) + if (len(curr.err) != 0) && err == nil { + t.Errorf("Expected error: %v, but got nil", curr.err) + } + if (len(curr.err) == 0) && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if (err != nil) && (err.Error() != curr.err) { + t.Errorf("Expected %v, got %v", curr.err, err) + + } + if b != curr.out { + t.Errorf("Expected %v, got %v", curr.out, b) + } + } + +} + +func testConfigCommand(args []string, startingConfig clientcmdapi.Config, t *testing.T) (string, clientcmdapi.Config) { + fakeKubeFile, _ := ioutil.TempFile("", "") + defer os.Remove(fakeKubeFile.Name()) + err := clientcmd.WriteToFile(startingConfig, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + argsToUse := make([]string, 0, 2+len(args)) + argsToUse = append(argsToUse, "--kubeconfig="+fakeKubeFile.Name()) + argsToUse = append(argsToUse, args...) + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdConfig(cmdutil.NewFactory(genericclioptions.NewTestConfigFlags()), clientcmd.NewDefaultPathOptions(), streams) + // "context" is a global flag, inherited from base kubectl command in the real world + cmd.PersistentFlags().String("context", "", "The name of the kubeconfig context to use") + cmd.SetArgs(argsToUse) + cmd.Execute() + + config := clientcmd.GetConfigFromFileOrDie(fakeKubeFile.Name()) + return buf.String(), *config +} + +type configCommandTest struct { + args []string + startingConfig clientcmdapi.Config + expectedConfig clientcmdapi.Config + expectedOutputs []string +} + +func (test configCommandTest) checkOutput(out string, expectedOutputs []string, t *testing.T) { + for _, expectedOutput := range expectedOutputs { + if !strings.Contains(out, expectedOutput) { + t.Errorf("expected '%s' in output, got '%s'", expectedOutput, out) + } + } +} + +func (test configCommandTest) run(t *testing.T) string { + out, actualConfig := testConfigCommand(test.args, test.startingConfig, t) + + testSetNilMapsToEmpties(reflect.ValueOf(&test.expectedConfig)) + testSetNilMapsToEmpties(reflect.ValueOf(&actualConfig)) + testClearLocationOfOrigin(&actualConfig) + + if !apiequality.Semantic.DeepEqual(test.expectedConfig, actualConfig) { + t.Errorf("diff: %v", diff.ObjectDiff(test.expectedConfig, actualConfig)) + t.Errorf("expected: %#v\n actual: %#v", test.expectedConfig, actualConfig) + } + + test.checkOutput(out, test.expectedOutputs, t) + + return out +} +func testClearLocationOfOrigin(config *clientcmdapi.Config) { + for key, obj := range config.AuthInfos { + obj.LocationOfOrigin = "" + config.AuthInfos[key] = obj + } + for key, obj := range config.Clusters { + obj.LocationOfOrigin = "" + config.Clusters[key] = obj + } + for key, obj := range config.Contexts { + obj.LocationOfOrigin = "" + config.Contexts[key] = obj + } +} +func testSetNilMapsToEmpties(curr reflect.Value) { + actualCurrValue := curr + if curr.Kind() == reflect.Ptr { + actualCurrValue = curr.Elem() + } + + switch actualCurrValue.Kind() { + case reflect.Map: + for _, mapKey := range actualCurrValue.MapKeys() { + currMapValue := actualCurrValue.MapIndex(mapKey) + testSetNilMapsToEmpties(currMapValue) + } + + case reflect.Struct: + for fieldIndex := 0; fieldIndex < actualCurrValue.NumField(); fieldIndex++ { + currFieldValue := actualCurrValue.Field(fieldIndex) + + if currFieldValue.Kind() == reflect.Map && currFieldValue.IsNil() { + newValue := reflect.MakeMap(currFieldValue.Type()) + currFieldValue.Set(newValue) + } else { + testSetNilMapsToEmpties(currFieldValue.Addr()) + } + } + + } + +} diff --git a/pkg/cmd/config/create_authinfo.go b/pkg/cmd/config/create_authinfo.go new file mode 100644 index 000000000..5d08fbc83 --- /dev/null +++ b/pkg/cmd/config/create_authinfo.go @@ -0,0 +1,437 @@ +/* +Copyright 2014 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 config + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + cliflag "k8s.io/component-base/cli/flag" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +type createAuthInfoOptions struct { + configAccess clientcmd.ConfigAccess + name string + authPath cliflag.StringFlag + clientCertificate cliflag.StringFlag + clientKey cliflag.StringFlag + token cliflag.StringFlag + username cliflag.StringFlag + password cliflag.StringFlag + embedCertData cliflag.Tristate + authProvider cliflag.StringFlag + + authProviderArgs map[string]string + authProviderArgsToRemove []string + + execCommand cliflag.StringFlag + execAPIVersion cliflag.StringFlag + execArgs []string + execEnv map[string]string + execEnvToRemove []string +} + +const ( + flagAuthProvider = "auth-provider" + flagAuthProviderArg = "auth-provider-arg" + + flagExecCommand = "exec-command" + flagExecAPIVersion = "exec-api-version" + flagExecArg = "exec-arg" + flagExecEnv = "exec-env" +) + +var ( + createAuthInfoLong = fmt.Sprintf(templates.LongDesc(` + Sets a user entry in kubeconfig + + Specifying a name that already exists will merge new fields on top of existing values. + + Client-certificate flags: + --%v=certfile --%v=keyfile + + Bearer token flags: + --%v=bearer_token + + Basic auth flags: + --%v=basic_user --%v=basic_password + + Bearer token and basic auth are mutually exclusive.`), clientcmd.FlagCertFile, clientcmd.FlagKeyFile, clientcmd.FlagBearerToken, clientcmd.FlagUsername, clientcmd.FlagPassword) + + createAuthInfoExample = templates.Examples(` + # Set only the "client-key" field on the "cluster-admin" + # entry, without touching other values: + kubectl config set-credentials cluster-admin --client-key=~/.kube/admin.key + + # Set basic auth for the "cluster-admin" entry + kubectl config set-credentials cluster-admin --username=admin --password=uXFGweU9l35qcif + + # Embed client certificate data in the "cluster-admin" entry + kubectl config set-credentials cluster-admin --client-certificate=~/.kube/admin.crt --embed-certs=true + + # Enable the Google Compute Platform auth provider for the "cluster-admin" entry + kubectl config set-credentials cluster-admin --auth-provider=gcp + + # Enable the OpenID Connect auth provider for the "cluster-admin" entry with additional args + kubectl config set-credentials cluster-admin --auth-provider=oidc --auth-provider-arg=client-id=foo --auth-provider-arg=client-secret=bar + + # Remove the "client-secret" config value for the OpenID Connect auth provider for the "cluster-admin" entry + kubectl config set-credentials cluster-admin --auth-provider=oidc --auth-provider-arg=client-secret- + + # Enable new exec auth plugin for the "cluster-admin" entry + kubectl config set-credentials cluster-admin --exec-command=/path/to/the/executable --exec-api-version=client.authentication.k8s.io/v1beta + + # Define new exec auth plugin args for the "cluster-admin" entry + kubectl config set-credentials cluster-admin --exec-arg=arg1 --exec-arg=arg2 + + # Create or update exec auth plugin environment variables for the "cluster-admin" entry + kubectl config set-credentials cluster-admin --exec-env=key1=val1 --exec-env=key2=val2 + + # Remove exec auth plugin environment variables for the "cluster-admin" entry + kubectl config set-credentials cluster-admin --exec-env=var-to-remove-`) +) + +// NewCmdConfigSetAuthInfo returns an Command option instance for 'config set-credentials' sub command +func NewCmdConfigSetAuthInfo(out io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + options := &createAuthInfoOptions{configAccess: configAccess} + return newCmdConfigSetAuthInfo(out, options) +} + +func newCmdConfigSetAuthInfo(out io.Writer, options *createAuthInfoOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf( + "set-credentials NAME [--%v=path/to/certfile] "+ + "[--%v=path/to/keyfile] "+ + "[--%v=bearer_token] "+ + "[--%v=basic_user] "+ + "[--%v=basic_password] "+ + "[--%v=provider_name] "+ + "[--%v=key=value] "+ + "[--%v=exec_command] "+ + "[--%v=exec_api_version] "+ + "[--%v=arg] "+ + "[--%v=key=value]", + clientcmd.FlagCertFile, + clientcmd.FlagKeyFile, + clientcmd.FlagBearerToken, + clientcmd.FlagUsername, + clientcmd.FlagPassword, + flagAuthProvider, + flagAuthProviderArg, + flagExecCommand, + flagExecAPIVersion, + flagExecArg, + flagExecEnv, + ), + DisableFlagsInUseLine: true, + Short: i18n.T("Sets a user entry in kubeconfig"), + Long: createAuthInfoLong, + Example: createAuthInfoExample, + Run: func(cmd *cobra.Command, args []string) { + err := options.complete(cmd, out) + if err != nil { + cmd.Help() + cmdutil.CheckErr(err) + } + cmdutil.CheckErr(options.run()) + fmt.Fprintf(out, "User %q set.\n", options.name) + }, + } + + cmd.Flags().Var(&options.clientCertificate, clientcmd.FlagCertFile, "Path to "+clientcmd.FlagCertFile+" file for the user entry in kubeconfig") + cmd.MarkFlagFilename(clientcmd.FlagCertFile) + cmd.Flags().Var(&options.clientKey, clientcmd.FlagKeyFile, "Path to "+clientcmd.FlagKeyFile+" file for the user entry in kubeconfig") + cmd.MarkFlagFilename(clientcmd.FlagKeyFile) + cmd.Flags().Var(&options.token, clientcmd.FlagBearerToken, clientcmd.FlagBearerToken+" for the user entry in kubeconfig") + cmd.Flags().Var(&options.username, clientcmd.FlagUsername, clientcmd.FlagUsername+" for the user entry in kubeconfig") + cmd.Flags().Var(&options.password, clientcmd.FlagPassword, clientcmd.FlagPassword+" for the user entry in kubeconfig") + cmd.Flags().Var(&options.authProvider, flagAuthProvider, "Auth provider for the user entry in kubeconfig") + cmd.Flags().StringSlice(flagAuthProviderArg, nil, "'key=value' arguments for the auth provider") + cmd.Flags().Var(&options.execCommand, flagExecCommand, "Command for the exec credential plugin for the user entry in kubeconfig") + cmd.Flags().Var(&options.execAPIVersion, flagExecAPIVersion, "API version of the exec credential plugin for the user entry in kubeconfig") + cmd.Flags().StringSlice(flagExecArg, nil, "New arguments for the exec credential plugin command for the user entry in kubeconfig") + cmd.Flags().StringArray(flagExecEnv, nil, "'key=value' environment values for the exec credential plugin") + f := cmd.Flags().VarPF(&options.embedCertData, clientcmd.FlagEmbedCerts, "", "Embed client cert/key for the user entry in kubeconfig") + f.NoOptDefVal = "true" + + return cmd +} + +func (o createAuthInfoOptions) run() error { + err := o.validate() + if err != nil { + return err + } + + config, err := o.configAccess.GetStartingConfig() + if err != nil { + return err + } + + startingStanza, exists := config.AuthInfos[o.name] + if !exists { + startingStanza = clientcmdapi.NewAuthInfo() + } + authInfo := o.modifyAuthInfo(*startingStanza) + config.AuthInfos[o.name] = &authInfo + + if err := clientcmd.ModifyConfig(o.configAccess, *config, true); err != nil { + return err + } + + return nil +} + +// authInfo builds an AuthInfo object from the options +func (o *createAuthInfoOptions) modifyAuthInfo(existingAuthInfo clientcmdapi.AuthInfo) clientcmdapi.AuthInfo { + modifiedAuthInfo := existingAuthInfo + + var setToken, setBasic bool + + if o.clientCertificate.Provided() { + certPath := o.clientCertificate.Value() + if o.embedCertData.Value() { + modifiedAuthInfo.ClientCertificateData, _ = ioutil.ReadFile(certPath) + modifiedAuthInfo.ClientCertificate = "" + } else { + certPath, _ = filepath.Abs(certPath) + modifiedAuthInfo.ClientCertificate = certPath + if len(modifiedAuthInfo.ClientCertificate) > 0 { + modifiedAuthInfo.ClientCertificateData = nil + } + } + } + if o.clientKey.Provided() { + keyPath := o.clientKey.Value() + if o.embedCertData.Value() { + modifiedAuthInfo.ClientKeyData, _ = ioutil.ReadFile(keyPath) + modifiedAuthInfo.ClientKey = "" + } else { + keyPath, _ = filepath.Abs(keyPath) + modifiedAuthInfo.ClientKey = keyPath + if len(modifiedAuthInfo.ClientKey) > 0 { + modifiedAuthInfo.ClientKeyData = nil + } + } + } + + if o.token.Provided() { + modifiedAuthInfo.Token = o.token.Value() + setToken = len(modifiedAuthInfo.Token) > 0 + } + + if o.username.Provided() { + modifiedAuthInfo.Username = o.username.Value() + setBasic = setBasic || len(modifiedAuthInfo.Username) > 0 + } + if o.password.Provided() { + modifiedAuthInfo.Password = o.password.Value() + setBasic = setBasic || len(modifiedAuthInfo.Password) > 0 + } + if o.authProvider.Provided() { + newName := o.authProvider.Value() + + // Only overwrite if the existing auth-provider is nil, or different than the newly specified one. + if modifiedAuthInfo.AuthProvider == nil || modifiedAuthInfo.AuthProvider.Name != newName { + modifiedAuthInfo.AuthProvider = &clientcmdapi.AuthProviderConfig{ + Name: newName, + } + } + } + + if modifiedAuthInfo.AuthProvider != nil { + if modifiedAuthInfo.AuthProvider.Config == nil { + modifiedAuthInfo.AuthProvider.Config = make(map[string]string) + } + for _, toRemove := range o.authProviderArgsToRemove { + delete(modifiedAuthInfo.AuthProvider.Config, toRemove) + } + for key, value := range o.authProviderArgs { + modifiedAuthInfo.AuthProvider.Config[key] = value + } + } + + if o.execCommand.Provided() { + newExecCommand := o.execCommand.Value() + + // create new Exec if doesn't exist, otherwise just modify the command + if modifiedAuthInfo.Exec == nil { + modifiedAuthInfo.Exec = &clientcmdapi.ExecConfig{ + Command: newExecCommand, + } + } else { + modifiedAuthInfo.Exec.Command = newExecCommand + // explicitly reset exec arguments + modifiedAuthInfo.Exec.Args = nil + } + } + + // modify next values only if Exec exists, ignore these changes otherwise + if modifiedAuthInfo.Exec != nil { + if o.execAPIVersion.Provided() { + modifiedAuthInfo.Exec.APIVersion = o.execAPIVersion.Value() + } + + // rewrite exec arguments list with new values + if o.execArgs != nil { + modifiedAuthInfo.Exec.Args = o.execArgs + } + + // iterate over the existing exec env values and remove the specified + if o.execEnvToRemove != nil { + newExecEnv := []clientcmdapi.ExecEnvVar{} + for _, value := range modifiedAuthInfo.Exec.Env { + needToRemove := false + for _, elemToRemove := range o.execEnvToRemove { + if value.Name == elemToRemove { + needToRemove = true + break + } + } + if !needToRemove { + newExecEnv = append(newExecEnv, value) + } + } + modifiedAuthInfo.Exec.Env = newExecEnv + } + + // update or create specified environment variables for the exec plugin + if o.execEnv != nil { + newEnv := []clientcmdapi.ExecEnvVar{} + for newEnvName, newEnvValue := range o.execEnv { + needToCreate := true + for i := 0; i < len(modifiedAuthInfo.Exec.Env); i++ { + if modifiedAuthInfo.Exec.Env[i].Name == newEnvName { + // update the existing value + needToCreate = false + modifiedAuthInfo.Exec.Env[i].Value = newEnvValue + break + } + } + if needToCreate { + // create a new env value + newEnv = append(newEnv, clientcmdapi.ExecEnvVar{Name: newEnvName, Value: newEnvValue}) + } + } + modifiedAuthInfo.Exec.Env = append(modifiedAuthInfo.Exec.Env, newEnv...) + } + } + + // If any auth info was set, make sure any other existing auth types are cleared + if setToken || setBasic { + if !setToken { + modifiedAuthInfo.Token = "" + } + if !setBasic { + modifiedAuthInfo.Username = "" + modifiedAuthInfo.Password = "" + } + } + + return modifiedAuthInfo +} + +func (o *createAuthInfoOptions) complete(cmd *cobra.Command, out io.Writer) error { + args := cmd.Flags().Args() + if len(args) != 1 { + return fmt.Errorf("Unexpected args: %v", args) + } + + authProviderArgs, err := cmd.Flags().GetStringSlice(flagAuthProviderArg) + if err != nil { + return fmt.Errorf("Error: %s", err) + } + + if len(authProviderArgs) > 0 { + newPairs, removePairs, err := cmdutil.ParsePairs(authProviderArgs, flagAuthProviderArg, true) + if err != nil { + return fmt.Errorf("Error: %s", err) + } + o.authProviderArgs = newPairs + o.authProviderArgsToRemove = removePairs + } + + execArgs, err := cmd.Flags().GetStringSlice(flagExecArg) + if err != nil { + return fmt.Errorf("Error: %s", err) + } + if len(execArgs) > 0 { + o.execArgs = execArgs + } + + execEnv, err := cmd.Flags().GetStringArray(flagExecEnv) + if err != nil { + return fmt.Errorf("Error: %s", err) + } + if len(execEnv) > 0 { + newPairs, removePairs, err := cmdutil.ParsePairs(execEnv, flagExecEnv, true) + if err != nil { + return fmt.Errorf("Error: %s", err) + } + o.execEnv = newPairs + o.execEnvToRemove = removePairs + } + + o.name = args[0] + return nil +} + +func (o createAuthInfoOptions) validate() error { + if len(o.name) == 0 { + return errors.New("you must specify a non-empty user name") + } + methods := []string{} + if len(o.token.Value()) > 0 { + methods = append(methods, fmt.Sprintf("--%v", clientcmd.FlagBearerToken)) + } + if len(o.username.Value()) > 0 || len(o.password.Value()) > 0 { + methods = append(methods, fmt.Sprintf("--%v/--%v", clientcmd.FlagUsername, clientcmd.FlagPassword)) + } + if len(methods) > 1 { + return fmt.Errorf("you cannot specify more than one authentication method at the same time: %v", strings.Join(methods, ", ")) + } + if o.embedCertData.Value() { + certPath := o.clientCertificate.Value() + keyPath := o.clientKey.Value() + if certPath == "" && keyPath == "" { + return fmt.Errorf("you must specify a --%s or --%s to embed", clientcmd.FlagCertFile, clientcmd.FlagKeyFile) + } + if certPath != "" { + if _, err := ioutil.ReadFile(certPath); err != nil { + return fmt.Errorf("error reading %s data from %s: %v", clientcmd.FlagCertFile, certPath, err) + } + } + if keyPath != "" { + if _, err := ioutil.ReadFile(keyPath); err != nil { + return fmt.Errorf("error reading %s data from %s: %v", clientcmd.FlagKeyFile, keyPath, err) + } + } + } + + return nil +} diff --git a/pkg/cmd/config/create_authinfo_test.go b/pkg/cmd/config/create_authinfo_test.go new file mode 100644 index 000000000..452990507 --- /dev/null +++ b/pkg/cmd/config/create_authinfo_test.go @@ -0,0 +1,493 @@ +/* +Copyright 2016 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 config + +import ( + "bytes" + "io/ioutil" + "os" + "reflect" + "testing" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + cliflag "k8s.io/component-base/cli/flag" +) + +func stringFlagFor(s string) cliflag.StringFlag { + var f cliflag.StringFlag + f.Set(s) + return f +} + +func TestCreateAuthInfoOptions(t *testing.T) { + tests := []struct { + name string + flags []string + wantParseErr bool + wantCompleteErr bool + wantValidateErr bool + + wantOptions *createAuthInfoOptions + }{ + { + name: "test1", + flags: []string{ + "me", + }, + wantOptions: &createAuthInfoOptions{ + name: "me", + }, + }, + { + name: "test2", + flags: []string{ + "me", + "--token=foo", + }, + wantOptions: &createAuthInfoOptions{ + name: "me", + token: stringFlagFor("foo"), + }, + }, + { + name: "test3", + flags: []string{ + "me", + "--username=jane", + "--password=bar", + }, + wantOptions: &createAuthInfoOptions{ + name: "me", + username: stringFlagFor("jane"), + password: stringFlagFor("bar"), + }, + }, + { + name: "test4", + // Cannot provide both token and basic auth. + flags: []string{ + "me", + "--token=foo", + "--username=jane", + "--password=bar", + }, + wantValidateErr: true, + }, + { + name: "test5", + flags: []string{ + "--auth-provider=oidc", + "--auth-provider-arg=client-id=foo", + "--auth-provider-arg=client-secret=bar", + "me", + }, + wantOptions: &createAuthInfoOptions{ + name: "me", + authProvider: stringFlagFor("oidc"), + authProviderArgs: map[string]string{ + "client-id": "foo", + "client-secret": "bar", + }, + authProviderArgsToRemove: []string{}, + }, + }, + { + name: "test6", + flags: []string{ + "--auth-provider=oidc", + "--auth-provider-arg=client-id-", + "--auth-provider-arg=client-secret-", + "me", + }, + wantOptions: &createAuthInfoOptions{ + name: "me", + authProvider: stringFlagFor("oidc"), + authProviderArgs: map[string]string{}, + authProviderArgsToRemove: []string{ + "client-id", + "client-secret", + }, + }, + }, + { + name: "test7", + flags: []string{ + "--auth-provider-arg=client-id-", // auth provider name not required + "--auth-provider-arg=client-secret-", + "me", + }, + wantOptions: &createAuthInfoOptions{ + name: "me", + authProviderArgs: map[string]string{}, + authProviderArgsToRemove: []string{ + "client-id", + "client-secret", + }, + }, + }, + { + name: "test8", + flags: []string{ + "--auth-provider=oidc", + "--auth-provider-arg=client-id", // values must be of form 'key=value' or 'key-' + "me", + }, + wantCompleteErr: true, + }, + { + name: "test9", + flags: []string{ + // No name for authinfo provided. + }, + wantCompleteErr: true, + }, + { + name: "test10", + flags: []string{ + "--exec-command=example-client-go-exec-plugin", + "me", + }, + wantOptions: &createAuthInfoOptions{ + name: "me", + execCommand: stringFlagFor("example-client-go-exec-plugin"), + }, + }, + { + name: "test11", + flags: []string{ + "--exec-command=example-client-go-exec-plugin", + "--exec-arg=arg1", + "--exec-arg=arg2", + "me", + }, + wantOptions: &createAuthInfoOptions{ + name: "me", + execCommand: stringFlagFor("example-client-go-exec-plugin"), + execArgs: []string{"arg1", "arg2"}, + }, + }, + { + name: "test12", + flags: []string{ + "--exec-command=example-client-go-exec-plugin", + "--exec-env=key1=val1", + "--exec-env=key2=val2", + "--exec-env=env-remove1-", + "--exec-env=env-remove2-", + "me", + }, + wantOptions: &createAuthInfoOptions{ + name: "me", + execCommand: stringFlagFor("example-client-go-exec-plugin"), + execEnv: map[string]string{"key1": "val1", "key2": "val2"}, + execEnvToRemove: []string{"env-remove1", "env-remove2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buff := new(bytes.Buffer) + + opts := new(createAuthInfoOptions) + cmd := newCmdConfigSetAuthInfo(buff, opts) + if err := cmd.ParseFlags(tt.flags); err != nil { + if !tt.wantParseErr { + t.Errorf("case %s: parsing error for flags %q: %v: %s", tt.name, tt.flags, err, buff) + } + return + } + if tt.wantParseErr { + t.Errorf("case %s: expected parsing error for flags %q: %s", tt.name, tt.flags, buff) + return + } + + if err := opts.complete(cmd, buff); err != nil { + if !tt.wantCompleteErr { + t.Errorf("case %s: complete() error for flags %q: %s", tt.name, tt.flags, buff) + } + return + } + if tt.wantCompleteErr { + t.Errorf("case %s: complete() expected errors for flags %q: %s", tt.name, tt.flags, buff) + return + } + + if err := opts.validate(); err != nil { + if !tt.wantValidateErr { + t.Errorf("case %s: flags %q: validate failed: %v", tt.name, tt.flags, err) + } + return + } + + if tt.wantValidateErr { + t.Errorf("case %s: flags %q: expected validate to fail", tt.name, tt.flags) + return + } + + if !reflect.DeepEqual(opts, tt.wantOptions) { + t.Errorf("case %s: flags %q: mis-matched options,\nwanted=%#v\ngot= %#v", tt.name, tt.flags, tt.wantOptions, opts) + } + }) + } +} + +func TestModifyExistingAuthInfo(t *testing.T) { + tests := []struct { + name string + flags []string + wantParseErr bool + wantCompleteErr bool + wantValidateErr bool + + existingAuthInfo clientcmdapi.AuthInfo + wantAuthInfo clientcmdapi.AuthInfo + }{ + { + name: "1. create new exec config", + flags: []string{ + "--exec-command=example-client-go-exec-plugin", + "--exec-api-version=client.authentication.k8s.io/v1", + "me", + }, + existingAuthInfo: clientcmdapi.AuthInfo{}, + wantAuthInfo: clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "example-client-go-exec-plugin", + APIVersion: "client.authentication.k8s.io/v1", + }, + }, + }, + { + name: "2. redefine exec args", + flags: []string{ + "--exec-arg=new-arg1", + "--exec-arg=new-arg2", + "me", + }, + existingAuthInfo: clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "example-client-go-exec-plugin", + APIVersion: "client.authentication.k8s.io/v1beta1", + Args: []string{"existing-arg1", "existing-arg2"}, + }, + }, + wantAuthInfo: clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "example-client-go-exec-plugin", + APIVersion: "client.authentication.k8s.io/v1beta1", + Args: []string{"new-arg1", "new-arg2"}, + }, + }, + }, + { + name: "3. reset exec args", + flags: []string{ + "--exec-command=example-client-go-exec-plugin", + "me", + }, + existingAuthInfo: clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "example-client-go-exec-plugin", + APIVersion: "client.authentication.k8s.io/v1beta1", + Args: []string{"existing-arg1", "existing-arg2"}, + }, + }, + wantAuthInfo: clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "example-client-go-exec-plugin", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + }, + }, + { + name: "4. modify exec env variables", + flags: []string{ + "--exec-command=example-client-go-exec-plugin", + "--exec-env=name1=value1000", + "--exec-env=name3=value3", + "--exec-env=name2-", + "--exec-env=non-existing-", + "me", + }, + existingAuthInfo: clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "existing-command", + APIVersion: "client.authentication.k8s.io/v1beta1", + Env: []clientcmdapi.ExecEnvVar{ + {Name: "name1", Value: "value1"}, + {Name: "name2", Value: "value2"}, + }, + }, + }, + wantAuthInfo: clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "example-client-go-exec-plugin", + APIVersion: "client.authentication.k8s.io/v1beta1", + Env: []clientcmdapi.ExecEnvVar{ + {Name: "name1", Value: "value1000"}, + {Name: "name3", Value: "value3"}, + }, + }, + }, + }, + { + name: "5. modify auth provider arguments", + flags: []string{ + "--auth-provider=new-auth-provider", + "--auth-provider-arg=key1=val1000", + "--auth-provider-arg=key3=val3", + "--auth-provider-arg=key2-", + "--auth-provider-arg=non-existing-", + "me", + }, + existingAuthInfo: clientcmdapi.AuthInfo{ + AuthProvider: &clientcmdapi.AuthProviderConfig{ + Name: "auth-provider", + Config: map[string]string{ + "key1": "val1", + "key2": "val2", + }, + }, + }, + wantAuthInfo: clientcmdapi.AuthInfo{ + AuthProvider: &clientcmdapi.AuthProviderConfig{ + Name: "new-auth-provider", + Config: map[string]string{ + "key1": "val1000", + "key3": "val3", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buff := new(bytes.Buffer) + + opts := new(createAuthInfoOptions) + cmd := newCmdConfigSetAuthInfo(buff, opts) + if err := cmd.ParseFlags(tt.flags); err != nil { + if !tt.wantParseErr { + t.Errorf("case %s: parsing error for flags %q: %v: %s", tt.name, tt.flags, err, buff) + } + return + } + if tt.wantParseErr { + t.Errorf("case %s: expected parsing error for flags %q: %s", tt.name, tt.flags, buff) + return + } + + if err := opts.complete(cmd, buff); err != nil { + if !tt.wantCompleteErr { + t.Errorf("case %s: complete() error for flags %q: %s", tt.name, tt.flags, buff) + } + return + } + if tt.wantCompleteErr { + t.Errorf("case %s: complete() expected errors for flags %q: %s", tt.name, tt.flags, buff) + return + } + + if err := opts.validate(); err != nil { + if !tt.wantValidateErr { + t.Errorf("case %s: flags %q: validate failed: %v", tt.name, tt.flags, err) + } + return + } + + if tt.wantValidateErr { + t.Errorf("case %s: flags %q: expected validate to fail", tt.name, tt.flags) + return + } + + modifiedAuthInfo := opts.modifyAuthInfo(tt.existingAuthInfo) + + if !reflect.DeepEqual(modifiedAuthInfo, tt.wantAuthInfo) { + t.Errorf("case %s: flags %q: mis-matched auth info,\nwanted=%#v\ngot= %#v", tt.name, tt.flags, tt.wantAuthInfo, modifiedAuthInfo) + } + }) + } +} + +type createAuthInfoTest struct { + description string + config clientcmdapi.Config + args []string + flags []string + expected string + expectedConfig clientcmdapi.Config +} + +func TestCreateAuthInfo(t *testing.T) { + conf := clientcmdapi.Config{} + test := createAuthInfoTest{ + description: "Testing for create aythinfo", + config: conf, + args: []string{"cluster-admin"}, + flags: []string{ + "--username=admin", + "--password=uXFGweU9l35qcif", + }, + expected: `User "cluster-admin" set.` + "\n", + expectedConfig: clientcmdapi.Config{ + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "cluster-admin": {Username: "admin", Password: "uXFGweU9l35qcif"}}, + }, + } + test.run(t) +} +func (test createAuthInfoTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile(os.TempDir(), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.config, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdConfigSetAuthInfo(buf, pathOptions) + cmd.SetArgs(test.args) + cmd.Flags().Parse(test.flags) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error executing command: %v,kubectl config set-credentials args: %v,flags: %v", err, test.args, test.flags) + } + config, err := clientcmd.LoadFromFile(fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error loading kubeconfig file: %v", err) + } + if len(test.expected) != 0 { + if buf.String() != test.expected { + t.Errorf("Fail in %q:\n expected %v\n but got %v\n", test.description, test.expected, buf.String()) + } + } + if test.expectedConfig.AuthInfos != nil { + expectAuthInfo := test.expectedConfig.AuthInfos[test.args[0]] + actualAuthInfo := config.AuthInfos[test.args[0]] + if expectAuthInfo.Username != actualAuthInfo.Username || expectAuthInfo.Password != actualAuthInfo.Password { + t.Errorf("Fail in %q:\n expected AuthInfo%v\n but found %v in kubeconfig\n", test.description, expectAuthInfo, actualAuthInfo) + } + } +} diff --git a/pkg/cmd/config/create_cluster.go b/pkg/cmd/config/create_cluster.go new file mode 100644 index 000000000..6860b2ce0 --- /dev/null +++ b/pkg/cmd/config/create_cluster.go @@ -0,0 +1,180 @@ +/* +Copyright 2014 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 config + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "path/filepath" + + "github.com/spf13/cobra" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + cliflag "k8s.io/component-base/cli/flag" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +type createClusterOptions struct { + configAccess clientcmd.ConfigAccess + name string + server cliflag.StringFlag + insecureSkipTLSVerify cliflag.Tristate + certificateAuthority cliflag.StringFlag + embedCAData cliflag.Tristate +} + +var ( + createClusterLong = templates.LongDesc(` + Sets a cluster entry in kubeconfig. + + Specifying a name that already exists will merge new fields on top of existing values for those fields.`) + + createClusterExample = templates.Examples(` + # Set only the server field on the e2e cluster entry without touching other values. + kubectl config set-cluster e2e --server=https://1.2.3.4 + + # Embed certificate authority data for the e2e cluster entry + kubectl config set-cluster e2e --certificate-authority=~/.kube/e2e/kubernetes.ca.crt + + # Disable cert checking for the dev cluster entry + kubectl config set-cluster e2e --insecure-skip-tls-verify=true`) +) + +// NewCmdConfigSetCluster returns a Command instance for 'config set-cluster' sub command +func NewCmdConfigSetCluster(out io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + options := &createClusterOptions{configAccess: configAccess} + + cmd := &cobra.Command{ + Use: fmt.Sprintf("set-cluster NAME [--%v=server] [--%v=path/to/certificate/authority] [--%v=true]", clientcmd.FlagAPIServer, clientcmd.FlagCAFile, clientcmd.FlagInsecure), + DisableFlagsInUseLine: true, + Short: i18n.T("Sets a cluster entry in kubeconfig"), + Long: createClusterLong, + Example: createClusterExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.complete(cmd)) + cmdutil.CheckErr(options.run()) + fmt.Fprintf(out, "Cluster %q set.\n", options.name) + }, + } + + options.insecureSkipTLSVerify.Default(false) + + cmd.Flags().Var(&options.server, clientcmd.FlagAPIServer, clientcmd.FlagAPIServer+" for the cluster entry in kubeconfig") + f := cmd.Flags().VarPF(&options.insecureSkipTLSVerify, clientcmd.FlagInsecure, "", clientcmd.FlagInsecure+" for the cluster entry in kubeconfig") + f.NoOptDefVal = "true" + cmd.Flags().Var(&options.certificateAuthority, clientcmd.FlagCAFile, "Path to "+clientcmd.FlagCAFile+" file for the cluster entry in kubeconfig") + cmd.MarkFlagFilename(clientcmd.FlagCAFile) + f = cmd.Flags().VarPF(&options.embedCAData, clientcmd.FlagEmbedCerts, "", clientcmd.FlagEmbedCerts+" for the cluster entry in kubeconfig") + f.NoOptDefVal = "true" + + return cmd +} + +func (o createClusterOptions) run() error { + err := o.validate() + if err != nil { + return err + } + + config, err := o.configAccess.GetStartingConfig() + if err != nil { + return err + } + + startingStanza, exists := config.Clusters[o.name] + if !exists { + startingStanza = clientcmdapi.NewCluster() + } + cluster := o.modifyCluster(*startingStanza) + config.Clusters[o.name] = &cluster + + if err := clientcmd.ModifyConfig(o.configAccess, *config, true); err != nil { + return err + } + + return nil +} + +// cluster builds a Cluster object from the options +func (o *createClusterOptions) modifyCluster(existingCluster clientcmdapi.Cluster) clientcmdapi.Cluster { + modifiedCluster := existingCluster + + if o.server.Provided() { + modifiedCluster.Server = o.server.Value() + } + if o.insecureSkipTLSVerify.Provided() { + modifiedCluster.InsecureSkipTLSVerify = o.insecureSkipTLSVerify.Value() + // Specifying insecure mode clears any certificate authority + if modifiedCluster.InsecureSkipTLSVerify { + modifiedCluster.CertificateAuthority = "" + modifiedCluster.CertificateAuthorityData = nil + } + } + if o.certificateAuthority.Provided() { + caPath := o.certificateAuthority.Value() + if o.embedCAData.Value() { + modifiedCluster.CertificateAuthorityData, _ = ioutil.ReadFile(caPath) + modifiedCluster.InsecureSkipTLSVerify = false + modifiedCluster.CertificateAuthority = "" + } else { + caPath, _ = filepath.Abs(caPath) + modifiedCluster.CertificateAuthority = caPath + // Specifying a certificate authority file clears certificate authority data and insecure mode + if caPath != "" { + modifiedCluster.InsecureSkipTLSVerify = false + modifiedCluster.CertificateAuthorityData = nil + } + } + } + + return modifiedCluster +} + +func (o *createClusterOptions) complete(cmd *cobra.Command) error { + args := cmd.Flags().Args() + if len(args) != 1 { + return helpErrorf(cmd, "Unexpected args: %v", args) + } + + o.name = args[0] + return nil +} + +func (o createClusterOptions) validate() error { + if len(o.name) == 0 { + return errors.New("you must specify a non-empty cluster name") + } + if o.insecureSkipTLSVerify.Value() && o.certificateAuthority.Value() != "" { + return errors.New("you cannot specify a certificate authority and insecure mode at the same time") + } + if o.embedCAData.Value() { + caPath := o.certificateAuthority.Value() + if caPath == "" { + return fmt.Errorf("you must specify a --%s to embed", clientcmd.FlagCAFile) + } + if _, err := ioutil.ReadFile(caPath); err != nil { + return fmt.Errorf("could not read %s data from %s: %v", clientcmd.FlagCAFile, caPath, err) + } + } + + return nil +} diff --git a/pkg/cmd/config/create_cluster_test.go b/pkg/cmd/config/create_cluster_test.go new file mode 100644 index 000000000..f961468fa --- /dev/null +++ b/pkg/cmd/config/create_cluster_test.go @@ -0,0 +1,119 @@ +/* +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 config + +import ( + "bytes" + "io/ioutil" + "os" + "testing" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type createClusterTest struct { + description string + config clientcmdapi.Config + args []string + flags []string + expected string + expectedConfig clientcmdapi.Config +} + +func TestCreateCluster(t *testing.T) { + conf := clientcmdapi.Config{} + test := createClusterTest{ + description: "Testing 'kubectl config set-cluster' with a new cluster", + config: conf, + args: []string{"my-cluster"}, + flags: []string{ + "--server=http://192.168.0.1", + }, + expected: `Cluster "my-cluster" set.` + "\n", + expectedConfig: clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "my-cluster": {Server: "http://192.168.0.1"}, + }, + }, + } + test.run(t) +} + +func TestModifyCluster(t *testing.T) { + conf := clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "my-cluster": {Server: "https://192.168.0.1"}, + }, + } + test := createClusterTest{ + description: "Testing 'kubectl config set-cluster' with an existing cluster", + config: conf, + args: []string{"my-cluster"}, + flags: []string{ + "--server=https://192.168.0.99", + }, + expected: `Cluster "my-cluster" set.` + "\n", + expectedConfig: clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "my-cluster": {Server: "https://192.168.0.99"}, + }, + }, + } + test.run(t) +} + +func (test createClusterTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile(os.TempDir(), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.config, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdConfigSetCluster(buf, pathOptions) + cmd.SetArgs(test.args) + cmd.Flags().Parse(test.flags) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error executing command: %v, args: %v, flags: %v", err, test.args, test.flags) + } + config, err := clientcmd.LoadFromFile(fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error loading kubeconfig file: %v", err) + } + if len(test.expected) != 0 { + if buf.String() != test.expected { + t.Errorf("Failed in %q\n expected %v\n but got %v", test.description, test.expected, buf.String()) + } + } + if len(test.args) > 0 { + cluster, ok := config.Clusters[test.args[0]] + if !ok { + t.Errorf("expected cluster %v, but got nil", test.args[0]) + return + } + if cluster.Server != test.expectedConfig.Clusters[test.args[0]].Server { + t.Errorf("Fail in %q\n expected cluster server %v\n but got %v\n ", test.description, test.expectedConfig.Clusters[test.args[0]].Server, cluster.Server) + } + } +} diff --git a/pkg/cmd/config/create_context.go b/pkg/cmd/config/create_context.go new file mode 100644 index 000000000..e27e108eb --- /dev/null +++ b/pkg/cmd/config/create_context.go @@ -0,0 +1,153 @@ +/* +Copyright 2014 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 config + +import ( + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + cliflag "k8s.io/component-base/cli/flag" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +type createContextOptions struct { + configAccess clientcmd.ConfigAccess + name string + currContext bool + cluster cliflag.StringFlag + authInfo cliflag.StringFlag + namespace cliflag.StringFlag +} + +var ( + createContextLong = templates.LongDesc(` + Sets a context entry in kubeconfig + + Specifying a name that already exists will merge new fields on top of existing values for those fields.`) + + createContextExample = templates.Examples(` + # Set the user field on the gce context entry without touching other values + kubectl config set-context gce --user=cluster-admin`) +) + +// NewCmdConfigSetContext returns a Command instance for 'config set-context' sub command +func NewCmdConfigSetContext(out io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + options := &createContextOptions{configAccess: configAccess} + + cmd := &cobra.Command{ + Use: fmt.Sprintf("set-context [NAME | --current] [--%v=cluster_nickname] [--%v=user_nickname] [--%v=namespace]", clientcmd.FlagClusterName, clientcmd.FlagAuthInfoName, clientcmd.FlagNamespace), + DisableFlagsInUseLine: true, + Short: i18n.T("Sets a context entry in kubeconfig"), + Long: createContextLong, + Example: createContextExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.complete(cmd)) + name, exists, err := options.run() + cmdutil.CheckErr(err) + if exists { + fmt.Fprintf(out, "Context %q modified.\n", name) + } else { + fmt.Fprintf(out, "Context %q created.\n", name) + } + }, + } + + cmd.Flags().BoolVar(&options.currContext, "current", options.currContext, "Modify the current context") + cmd.Flags().Var(&options.cluster, clientcmd.FlagClusterName, clientcmd.FlagClusterName+" for the context entry in kubeconfig") + cmd.Flags().Var(&options.authInfo, clientcmd.FlagAuthInfoName, clientcmd.FlagAuthInfoName+" for the context entry in kubeconfig") + cmd.Flags().Var(&options.namespace, clientcmd.FlagNamespace, clientcmd.FlagNamespace+" for the context entry in kubeconfig") + + return cmd +} + +func (o createContextOptions) run() (string, bool, error) { + err := o.validate() + if err != nil { + return "", false, err + } + + config, err := o.configAccess.GetStartingConfig() + if err != nil { + return "", false, err + } + + name := o.name + if o.currContext { + if len(config.CurrentContext) == 0 { + return "", false, errors.New("no current context is set") + } + name = config.CurrentContext + } + + startingStanza, exists := config.Contexts[name] + if !exists { + startingStanza = clientcmdapi.NewContext() + } + context := o.modifyContext(*startingStanza) + config.Contexts[name] = &context + + if err := clientcmd.ModifyConfig(o.configAccess, *config, true); err != nil { + return name, exists, err + } + + return name, exists, nil +} + +func (o *createContextOptions) modifyContext(existingContext clientcmdapi.Context) clientcmdapi.Context { + modifiedContext := existingContext + + if o.cluster.Provided() { + modifiedContext.Cluster = o.cluster.Value() + } + if o.authInfo.Provided() { + modifiedContext.AuthInfo = o.authInfo.Value() + } + if o.namespace.Provided() { + modifiedContext.Namespace = o.namespace.Value() + } + + return modifiedContext +} + +func (o *createContextOptions) complete(cmd *cobra.Command) error { + args := cmd.Flags().Args() + if len(args) > 1 { + return helpErrorf(cmd, "Unexpected args: %v", args) + } + if len(args) == 1 { + o.name = args[0] + } + return nil +} + +func (o createContextOptions) validate() error { + if len(o.name) == 0 && !o.currContext { + return errors.New("you must specify a non-empty context name or --current") + } + if len(o.name) > 0 && o.currContext { + return errors.New("you cannot specify both a context name and --current") + } + + return nil +} diff --git a/pkg/cmd/config/create_context_test.go b/pkg/cmd/config/create_context_test.go new file mode 100644 index 000000000..f75a6dd7d --- /dev/null +++ b/pkg/cmd/config/create_context_test.go @@ -0,0 +1,147 @@ +/* +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 config + +import ( + "bytes" + "io/ioutil" + "os" + "testing" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type createContextTest struct { + description string + testContext string // name of the context being modified + config clientcmdapi.Config //initiate kubectl config + args []string //kubectl set-context args + flags []string //kubectl set-context flags + expected string //expectd out + expectedConfig clientcmdapi.Config //expect kubectl config +} + +func TestCreateContext(t *testing.T) { + conf := clientcmdapi.Config{} + test := createContextTest{ + testContext: "shaker-context", + description: "Testing for create a new context", + config: conf, + args: []string{"shaker-context"}, + flags: []string{ + "--cluster=cluster_nickname", + "--user=user_nickname", + "--namespace=namespace", + }, + expected: `Context "shaker-context" created.` + "\n", + expectedConfig: clientcmdapi.Config{ + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "user_nickname", Cluster: "cluster_nickname", Namespace: "namespace"}}, + }, + } + test.run(t) +} +func TestModifyContext(t *testing.T) { + conf := clientcmdapi.Config{ + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}, + "not-this": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}}} + test := createContextTest{ + testContext: "shaker-context", + description: "Testing for modify a already exist context", + config: conf, + args: []string{"shaker-context"}, + flags: []string{ + "--cluster=cluster_nickname", + "--user=user_nickname", + "--namespace=namespace", + }, + expected: `Context "shaker-context" modified.` + "\n", + expectedConfig: clientcmdapi.Config{ + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "user_nickname", Cluster: "cluster_nickname", Namespace: "namespace"}, + "not-this": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}}}, + } + test.run(t) +} + +func TestModifyCurrentContext(t *testing.T) { + conf := clientcmdapi.Config{ + CurrentContext: "shaker-context", + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}, + "not-this": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}}} + test := createContextTest{ + testContext: "shaker-context", + description: "Testing for modify a current context", + config: conf, + args: []string{}, + flags: []string{ + "--current", + "--cluster=cluster_nickname", + "--user=user_nickname", + "--namespace=namespace", + }, + expected: `Context "shaker-context" modified.` + "\n", + expectedConfig: clientcmdapi.Config{ + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "user_nickname", Cluster: "cluster_nickname", Namespace: "namespace"}, + "not-this": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}}}, + } + test.run(t) +} + +func (test createContextTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile(os.TempDir(), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.config, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdConfigSetContext(buf, pathOptions) + cmd.SetArgs(test.args) + cmd.Flags().Parse(test.flags) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error executing command: %v,kubectl set-context args: %v,flags: %v", err, test.args, test.flags) + } + config, err := clientcmd.LoadFromFile(fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error loading kubeconfig file: %v", err) + } + if len(test.expected) != 0 { + if buf.String() != test.expected { + t.Errorf("Fail in %q:\n expected %v\n but got %v\n", test.description, test.expected, buf.String()) + } + } + if test.expectedConfig.Contexts != nil { + expectContext := test.expectedConfig.Contexts[test.testContext] + actualContext := config.Contexts[test.testContext] + if expectContext.AuthInfo != actualContext.AuthInfo || expectContext.Cluster != actualContext.Cluster || + expectContext.Namespace != actualContext.Namespace { + t.Errorf("Fail in %q:\n expected Context %v\n but found %v in kubeconfig\n", test.description, expectContext, actualContext) + } + } +} diff --git a/pkg/cmd/config/current_context.go b/pkg/cmd/config/current_context.go new file mode 100644 index 000000000..cea2f6461 --- /dev/null +++ b/pkg/cmd/config/current_context.go @@ -0,0 +1,76 @@ +/* +Copyright 2014 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 config + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + "k8s.io/client-go/tools/clientcmd" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// CurrentContextOptions holds the command-line options for 'config current-context' sub command +type CurrentContextOptions struct { + ConfigAccess clientcmd.ConfigAccess +} + +var ( + currentContextLong = templates.LongDesc(` + Displays the current-context`) + + currentContextExample = templates.Examples(` + # Display the current-context + kubectl config current-context`) +) + +// NewCmdConfigCurrentContext returns a Command instance for 'config current-context' sub command +func NewCmdConfigCurrentContext(out io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + options := &CurrentContextOptions{ConfigAccess: configAccess} + + cmd := &cobra.Command{ + Use: "current-context", + Short: i18n.T("Displays the current-context"), + Long: currentContextLong, + Example: currentContextExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(RunCurrentContext(out, options)) + }, + } + + return cmd +} + +// RunCurrentContext performs the execution of 'config current-context' sub command +func RunCurrentContext(out io.Writer, options *CurrentContextOptions) error { + config, err := options.ConfigAccess.GetStartingConfig() + if err != nil { + return err + } + + if config.CurrentContext == "" { + err = fmt.Errorf("current-context is not set") + return err + } + + fmt.Fprintf(out, "%s\n", config.CurrentContext) + return nil +} diff --git a/pkg/cmd/config/current_context_test.go b/pkg/cmd/config/current_context_test.go new file mode 100644 index 000000000..69b466f42 --- /dev/null +++ b/pkg/cmd/config/current_context_test.go @@ -0,0 +1,93 @@ +/* +Copyright 2014 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 config + +import ( + "bytes" + "io/ioutil" + "os" + "strings" + "testing" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type currentContextTest struct { + startingConfig clientcmdapi.Config + expectedError string +} + +func newFederalContextConfig() clientcmdapi.Config { + return clientcmdapi.Config{ + CurrentContext: "federal-context", + } +} + +func TestCurrentContextWithSetContext(t *testing.T) { + test := currentContextTest{ + startingConfig: newFederalContextConfig(), + expectedError: "", + } + + test.run(t) +} + +func TestCurrentContextWithUnsetContext(t *testing.T) { + test := currentContextTest{ + startingConfig: *clientcmdapi.NewConfig(), + expectedError: "current-context is not set", + } + + test.run(t) +} + +func (test currentContextTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.startingConfig, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + options := CurrentContextOptions{ + ConfigAccess: pathOptions, + } + + buf := bytes.NewBuffer([]byte{}) + err = RunCurrentContext(buf, &options) + if len(test.expectedError) != 0 { + if err == nil { + t.Errorf("Did not get %v", test.expectedError) + } else { + if !strings.Contains(err.Error(), test.expectedError) { + t.Errorf("Expected %v, but got %v", test.expectedError, err) + } + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } +} diff --git a/pkg/cmd/config/delete_cluster.go b/pkg/cmd/config/delete_cluster.go new file mode 100644 index 000000000..a34686da2 --- /dev/null +++ b/pkg/cmd/config/delete_cluster.go @@ -0,0 +1,84 @@ +/* +Copyright 2016 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 config + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + deleteClusterExample = templates.Examples(` + # Delete the minikube cluster + kubectl config delete-cluster minikube`) +) + +// NewCmdConfigDeleteCluster returns a Command instance for 'config delete-cluster' sub command +func NewCmdConfigDeleteCluster(out io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete-cluster NAME", + DisableFlagsInUseLine: true, + Short: i18n.T("Delete the specified cluster from the kubeconfig"), + Long: "Delete the specified cluster from the kubeconfig", + Example: deleteClusterExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(runDeleteCluster(out, configAccess, cmd)) + }, + } + + return cmd +} + +func runDeleteCluster(out io.Writer, configAccess clientcmd.ConfigAccess, cmd *cobra.Command) error { + config, err := configAccess.GetStartingConfig() + if err != nil { + return err + } + + args := cmd.Flags().Args() + if len(args) != 1 { + cmd.Help() + return nil + } + + configFile := configAccess.GetDefaultFilename() + if configAccess.IsExplicitFile() { + configFile = configAccess.GetExplicitFile() + } + + name := args[0] + _, ok := config.Clusters[name] + if !ok { + return fmt.Errorf("cannot delete cluster %s, not in %s", name, configFile) + } + + delete(config.Clusters, name) + + if err := clientcmd.ModifyConfig(configAccess, *config, true); err != nil { + return err + } + + fmt.Fprintf(out, "deleted cluster %s from %s\n", name, configFile) + + return nil +} diff --git a/pkg/cmd/config/delete_cluster_test.go b/pkg/cmd/config/delete_cluster_test.go new file mode 100644 index 000000000..5e7457a68 --- /dev/null +++ b/pkg/cmd/config/delete_cluster_test.go @@ -0,0 +1,97 @@ +/* +Copyright 2016 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 config + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "reflect" + "testing" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type deleteClusterTest struct { + config clientcmdapi.Config + clusterToDelete string + expectedClusters []string + expectedOut string +} + +func TestDeleteCluster(t *testing.T) { + conf := clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "minikube": {Server: "https://192.168.0.99"}, + "otherkube": {Server: "https://192.168.0.100"}, + }, + } + test := deleteClusterTest{ + config: conf, + clusterToDelete: "minikube", + expectedClusters: []string{"otherkube"}, + expectedOut: "deleted cluster minikube from %s\n", + } + + test.run(t) +} + +func (test deleteClusterTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.config, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdConfigDeleteCluster(buf, pathOptions) + cmd.SetArgs([]string{test.clusterToDelete}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error executing command: %v", err) + } + + expectedOutWithFile := fmt.Sprintf(test.expectedOut, fakeKubeFile.Name()) + if expectedOutWithFile != buf.String() { + t.Errorf("expected output %s, but got %s", expectedOutWithFile, buf.String()) + return + } + + // Verify cluster was removed from kubeconfig file + config, err := clientcmd.LoadFromFile(fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error loading kubeconfig file: %v", err) + } + + clusters := make([]string, 0, len(config.Clusters)) + for k := range config.Clusters { + clusters = append(clusters, k) + } + + if !reflect.DeepEqual(test.expectedClusters, clusters) { + t.Errorf("expected clusters %v, but found %v in kubeconfig", test.expectedClusters, clusters) + } +} diff --git a/pkg/cmd/config/delete_context.go b/pkg/cmd/config/delete_context.go new file mode 100644 index 000000000..f019e159d --- /dev/null +++ b/pkg/cmd/config/delete_context.go @@ -0,0 +1,88 @@ +/* +Copyright 2016 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 config + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + deleteContextExample = templates.Examples(` + # Delete the context for the minikube cluster + kubectl config delete-context minikube`) +) + +// NewCmdConfigDeleteContext returns a Command instance for 'config delete-context' sub command +func NewCmdConfigDeleteContext(out, errOut io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete-context NAME", + DisableFlagsInUseLine: true, + Short: i18n.T("Delete the specified context from the kubeconfig"), + Long: "Delete the specified context from the kubeconfig", + Example: deleteContextExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(runDeleteContext(out, errOut, configAccess, cmd)) + }, + } + + return cmd +} + +func runDeleteContext(out, errOut io.Writer, configAccess clientcmd.ConfigAccess, cmd *cobra.Command) error { + config, err := configAccess.GetStartingConfig() + if err != nil { + return err + } + + args := cmd.Flags().Args() + if len(args) != 1 { + cmd.Help() + return nil + } + + configFile := configAccess.GetDefaultFilename() + if configAccess.IsExplicitFile() { + configFile = configAccess.GetExplicitFile() + } + + name := args[0] + _, ok := config.Contexts[name] + if !ok { + return fmt.Errorf("cannot delete context %s, not in %s", name, configFile) + } + + if config.CurrentContext == name { + fmt.Fprint(errOut, "warning: this removed your active context, use \"kubectl config use-context\" to select a different one\n") + } + + delete(config.Contexts, name) + + if err := clientcmd.ModifyConfig(configAccess, *config, true); err != nil { + return err + } + + fmt.Fprintf(out, "deleted context %s from %s\n", name, configFile) + + return nil +} diff --git a/pkg/cmd/config/delete_context_test.go b/pkg/cmd/config/delete_context_test.go new file mode 100644 index 000000000..8f7b95826 --- /dev/null +++ b/pkg/cmd/config/delete_context_test.go @@ -0,0 +1,98 @@ +/* +Copyright 2016 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 config + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "reflect" + "testing" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type deleteContextTest struct { + config clientcmdapi.Config + contextToDelete string + expectedContexts []string + expectedOut string +} + +func TestDeleteContext(t *testing.T) { + conf := clientcmdapi.Config{ + Contexts: map[string]*clientcmdapi.Context{ + "minikube": {Cluster: "minikube"}, + "otherkube": {Cluster: "otherkube"}, + }, + } + test := deleteContextTest{ + config: conf, + contextToDelete: "minikube", + expectedContexts: []string{"otherkube"}, + expectedOut: "deleted context minikube from %s\n", + } + + test.run(t) +} + +func (test deleteContextTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.config, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + + buf := bytes.NewBuffer([]byte{}) + errBuf := bytes.NewBuffer([]byte{}) + cmd := NewCmdConfigDeleteContext(buf, errBuf, pathOptions) + cmd.SetArgs([]string{test.contextToDelete}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error executing command: %v", err) + } + + expectedOutWithFile := fmt.Sprintf(test.expectedOut, fakeKubeFile.Name()) + if expectedOutWithFile != buf.String() { + t.Errorf("expected output %s, but got %s", expectedOutWithFile, buf.String()) + return + } + + // Verify context was removed from kubeconfig file + config, err := clientcmd.LoadFromFile(fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error loading kubeconfig file: %v", err) + } + + contexts := make([]string, 0, len(config.Contexts)) + for k := range config.Contexts { + contexts = append(contexts, k) + } + + if !reflect.DeepEqual(test.expectedContexts, contexts) { + t.Errorf("expected contexts %v, but found %v in kubeconfig", test.expectedContexts, contexts) + } +} diff --git a/pkg/cmd/config/get_clusters.go b/pkg/cmd/config/get_clusters.go new file mode 100644 index 000000000..5c3fd2e1c --- /dev/null +++ b/pkg/cmd/config/get_clusters.go @@ -0,0 +1,64 @@ +/* +Copyright 2016 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 config + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + getClustersExample = templates.Examples(` + # List the clusters kubectl knows about + kubectl config get-clusters`) +) + +// NewCmdConfigGetClusters creates a command object for the "get-clusters" action, which +// lists all clusters defined in the kubeconfig. +func NewCmdConfigGetClusters(out io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + cmd := &cobra.Command{ + Use: "get-clusters", + Short: i18n.T("Display clusters defined in the kubeconfig"), + Long: "Display clusters defined in the kubeconfig.", + Example: getClustersExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(runGetClusters(out, configAccess)) + }, + } + + return cmd +} + +func runGetClusters(out io.Writer, configAccess clientcmd.ConfigAccess) error { + config, err := configAccess.GetStartingConfig() + if err != nil { + return err + } + + fmt.Fprintf(out, "NAME\n") + for name := range config.Clusters { + fmt.Fprintf(out, "%s\n", name) + } + + return nil +} diff --git a/pkg/cmd/config/get_clusters_test.go b/pkg/cmd/config/get_clusters_test.go new file mode 100644 index 000000000..9eaf808f0 --- /dev/null +++ b/pkg/cmd/config/get_clusters_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2014 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 config + +import ( + "bytes" + "io/ioutil" + "os" + "testing" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type getClustersTest struct { + config clientcmdapi.Config + expected string +} + +func TestGetClusters(t *testing.T) { + conf := clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "minikube": {Server: "https://192.168.0.99"}, + }, + } + test := getClustersTest{ + config: conf, + expected: `NAME +minikube +`, + } + + test.run(t) +} + +func TestGetClustersEmpty(t *testing.T) { + test := getClustersTest{ + config: clientcmdapi.Config{}, + expected: "NAME\n", + } + + test.run(t) +} + +func (test getClustersTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.config, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdConfigGetClusters(buf, pathOptions) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error executing command: %v", err) + } + if len(test.expected) != 0 { + if buf.String() != test.expected { + t.Errorf("expected %v, but got %v", test.expected, buf.String()) + } + return + } +} diff --git a/pkg/cmd/config/get_contexts.go b/pkg/cmd/config/get_contexts.go new file mode 100644 index 000000000..72d02630e --- /dev/null +++ b/pkg/cmd/config/get_contexts.go @@ -0,0 +1,180 @@ +/* +Copyright 2016 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 config + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/liggitt/tabwriter" + "github.com/spf13/cobra" + + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/printers" + "k8s.io/kubectl/pkg/util/templates" +) + +// GetContextsOptions contains the assignable options from the args. +type GetContextsOptions struct { + configAccess clientcmd.ConfigAccess + nameOnly bool + showHeaders bool + contextNames []string + + genericclioptions.IOStreams +} + +var ( + getContextsLong = templates.LongDesc(`Displays one or many contexts from the kubeconfig file.`) + + getContextsExample = templates.Examples(` + # List all the contexts in your kubeconfig file + kubectl config get-contexts + + # Describe one context in your kubeconfig file. + kubectl config get-contexts my-context`) +) + +// NewCmdConfigGetContexts creates a command object for the "get-contexts" action, which +// retrieves one or more contexts from a kubeconfig. +func NewCmdConfigGetContexts(streams genericclioptions.IOStreams, configAccess clientcmd.ConfigAccess) *cobra.Command { + options := &GetContextsOptions{ + configAccess: configAccess, + + IOStreams: streams, + } + + cmd := &cobra.Command{ + Use: "get-contexts [(-o|--output=)name)]", + DisableFlagsInUseLine: true, + Short: i18n.T("Describe one or many contexts"), + Long: getContextsLong, + Example: getContextsExample, + Run: func(cmd *cobra.Command, args []string) { + validOutputTypes := sets.NewString("", "json", "yaml", "wide", "name", "custom-columns", "custom-columns-file", "go-template", "go-template-file", "jsonpath", "jsonpath-file") + supportedOutputTypes := sets.NewString("", "name") + outputFormat := cmdutil.GetFlagString(cmd, "output") + if !validOutputTypes.Has(outputFormat) { + cmdutil.CheckErr(fmt.Errorf("output must be one of '' or 'name': %v", outputFormat)) + } + if !supportedOutputTypes.Has(outputFormat) { + fmt.Fprintf(options.Out, "--output %v is not available in kubectl config get-contexts; resetting to default output format\n", outputFormat) + cmd.Flags().Set("output", "") + } + cmdutil.CheckErr(options.Complete(cmd, args)) + cmdutil.CheckErr(options.RunGetContexts()) + }, + } + + cmd.Flags().Bool("no-headers", false, "When using the default or custom-column output format, don't print headers (default print headers).") + cmd.Flags().StringP("output", "o", "", "Output format. One of: name") + return cmd +} + +// Complete assigns GetContextsOptions from the args. +func (o *GetContextsOptions) Complete(cmd *cobra.Command, args []string) error { + o.contextNames = args + o.nameOnly = false + if cmdutil.GetFlagString(cmd, "output") == "name" { + o.nameOnly = true + } + o.showHeaders = true + if cmdutil.GetFlagBool(cmd, "no-headers") || o.nameOnly { + o.showHeaders = false + } + + return nil +} + +// RunGetContexts implements all the necessary functionality for context retrieval. +func (o GetContextsOptions) RunGetContexts() error { + config, err := o.configAccess.GetStartingConfig() + if err != nil { + return err + } + + out, found := o.Out.(*tabwriter.Writer) + if !found { + out = printers.GetNewTabWriter(o.Out) + defer out.Flush() + } + + // Build a list of context names to print, and warn if any requested contexts are not found. + // Do this before printing the headers so it doesn't look ugly. + allErrs := []error{} + toPrint := []string{} + if len(o.contextNames) == 0 { + for name := range config.Contexts { + toPrint = append(toPrint, name) + } + } else { + for _, name := range o.contextNames { + _, ok := config.Contexts[name] + if ok { + toPrint = append(toPrint, name) + } else { + allErrs = append(allErrs, fmt.Errorf("context %v not found", name)) + } + } + } + if o.showHeaders { + err = printContextHeaders(out, o.nameOnly) + if err != nil { + allErrs = append(allErrs, err) + } + } + + sort.Strings(toPrint) + for _, name := range toPrint { + err = printContext(name, config.Contexts[name], out, o.nameOnly, config.CurrentContext == name) + if err != nil { + allErrs = append(allErrs, err) + } + } + + return utilerrors.NewAggregate(allErrs) +} + +func printContextHeaders(out io.Writer, nameOnly bool) error { + columnNames := []string{"CURRENT", "NAME", "CLUSTER", "AUTHINFO", "NAMESPACE"} + if nameOnly { + columnNames = columnNames[:1] + } + _, err := fmt.Fprintf(out, "%s\n", strings.Join(columnNames, "\t")) + return err +} + +func printContext(name string, context *clientcmdapi.Context, w io.Writer, nameOnly, current bool) error { + if nameOnly { + _, err := fmt.Fprintf(w, "%s\n", name) + return err + } + prefix := " " + if current { + prefix = "*" + } + _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", prefix, name, context.Cluster, context.AuthInfo, context.Namespace) + return err +} diff --git a/pkg/cmd/config/get_contexts_test.go b/pkg/cmd/config/get_contexts_test.go new file mode 100644 index 000000000..dbf5dbe1d --- /dev/null +++ b/pkg/cmd/config/get_contexts_test.go @@ -0,0 +1,178 @@ +/* +Copyright 2014 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 config + +import ( + "io/ioutil" + "os" + "testing" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type getContextsTest struct { + startingConfig clientcmdapi.Config + names []string + noHeader bool + nameOnly bool + expectedOut string +} + +func TestGetContextsAll(t *testing.T) { + tconf := clientcmdapi.Config{ + CurrentContext: "shaker-context", + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}}} + test := getContextsTest{ + startingConfig: tconf, + names: []string{}, + noHeader: false, + nameOnly: false, + expectedOut: `CURRENT NAME CLUSTER AUTHINFO NAMESPACE +* shaker-context big-cluster blue-user saw-ns +`, + } + test.run(t) +} + +func TestGetContextsAllNoHeader(t *testing.T) { + tconf := clientcmdapi.Config{ + CurrentContext: "shaker-context", + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}}} + test := getContextsTest{ + startingConfig: tconf, + names: []string{}, + noHeader: true, + nameOnly: false, + expectedOut: "* shaker-context big-cluster blue-user saw-ns\n", + } + test.run(t) +} + +func TestGetContextsAllSorted(t *testing.T) { + tconf := clientcmdapi.Config{ + CurrentContext: "shaker-context", + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}, + "abc": {AuthInfo: "blue-user", Cluster: "abc-cluster", Namespace: "kube-system"}, + "xyz": {AuthInfo: "blue-user", Cluster: "xyz-cluster", Namespace: "default"}}} + test := getContextsTest{ + startingConfig: tconf, + names: []string{}, + noHeader: false, + nameOnly: false, + expectedOut: `CURRENT NAME CLUSTER AUTHINFO NAMESPACE + abc abc-cluster blue-user kube-system +* shaker-context big-cluster blue-user saw-ns + xyz xyz-cluster blue-user default +`, + } + test.run(t) +} + +func TestGetContextsAllName(t *testing.T) { + tconf := clientcmdapi.Config{ + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}}} + test := getContextsTest{ + startingConfig: tconf, + names: []string{}, + noHeader: false, + nameOnly: true, + expectedOut: "shaker-context\n", + } + test.run(t) +} + +func TestGetContextsAllNameNoHeader(t *testing.T) { + tconf := clientcmdapi.Config{ + CurrentContext: "shaker-context", + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}}} + test := getContextsTest{ + startingConfig: tconf, + names: []string{}, + noHeader: true, + nameOnly: true, + expectedOut: "shaker-context\n", + } + test.run(t) +} + +func TestGetContextsAllNone(t *testing.T) { + test := getContextsTest{ + startingConfig: *clientcmdapi.NewConfig(), + names: []string{}, + noHeader: true, + nameOnly: false, + expectedOut: "", + } + test.run(t) +} + +func TestGetContextsSelectOneOfTwo(t *testing.T) { + tconf := clientcmdapi.Config{ + CurrentContext: "shaker-context", + Contexts: map[string]*clientcmdapi.Context{ + "shaker-context": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}, + "not-this": {AuthInfo: "blue-user", Cluster: "big-cluster", Namespace: "saw-ns"}}} + test := getContextsTest{ + startingConfig: tconf, + names: []string{"shaker-context"}, + noHeader: true, + nameOnly: true, + expectedOut: "shaker-context\n", + } + test.run(t) +} + +func (test getContextsTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.startingConfig, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + options := GetContextsOptions{ + configAccess: pathOptions, + } + cmd := NewCmdConfigGetContexts(streams, options.configAccess) + if test.nameOnly { + cmd.Flags().Set("output", "name") + } + if test.noHeader { + cmd.Flags().Set("no-headers", "true") + } + cmd.Run(cmd, test.names) + if len(test.expectedOut) != 0 { + if buf.String() != test.expectedOut { + t.Errorf("Expected\n%s\ngot\n%s", test.expectedOut, buf.String()) + } + return + } +} diff --git a/pkg/cmd/config/navigation_step_parser.go b/pkg/cmd/config/navigation_step_parser.go new file mode 100644 index 000000000..0899589ad --- /dev/null +++ b/pkg/cmd/config/navigation_step_parser.go @@ -0,0 +1,154 @@ +/* +Copyright 2014 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 config + +import ( + "fmt" + "reflect" + "strings" + + "k8s.io/apimachinery/pkg/util/sets" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type navigationSteps struct { + steps []navigationStep + currentStepIndex int +} + +type navigationStep struct { + stepValue string + stepType reflect.Type +} + +func newNavigationSteps(path string) (*navigationSteps, error) { + steps := []navigationStep{} + individualParts := strings.Split(path, ".") + + currType := reflect.TypeOf(clientcmdapi.Config{}) + currPartIndex := 0 + for currPartIndex < len(individualParts) { + switch currType.Kind() { + case reflect.Map: + // if we're in a map, we need to locate a name. That name may contain dots, so we need to know what tokens are legal for the map's value type + // for example, we could have a set request like: `set clusters.10.10.12.56.insecure-skip-tls-verify true`. We enter this case with + // steps representing 10, 10, 12, 56, insecure-skip-tls-verify. The name is "10.10.12.56", so we want to collect all those parts together and + // store them as a single step. In order to do that, we need to determine what set of tokens is a legal step AFTER the name of the map key + // This set of reflective code pulls the type of the map values, uses that type to look up the set of legal tags. Those legal tags are used to + // walk the list of remaining parts until we find a match to a legal tag or the end of the string. That name is used to burn all the used parts. + mapValueType := currType.Elem().Elem() + mapValueOptions, err := getPotentialTypeValues(mapValueType) + if err != nil { + return nil, err + } + nextPart := findNameStep(individualParts[currPartIndex:], sets.StringKeySet(mapValueOptions)) + + steps = append(steps, navigationStep{nextPart, mapValueType}) + currPartIndex += len(strings.Split(nextPart, ".")) + currType = mapValueType + + case reflect.Struct: + nextPart := individualParts[currPartIndex] + + options, err := getPotentialTypeValues(currType) + if err != nil { + return nil, err + } + fieldType, exists := options[nextPart] + if !exists { + return nil, fmt.Errorf("unable to parse %v after %v at %v", path, steps, currType) + } + + steps = append(steps, navigationStep{nextPart, fieldType}) + currPartIndex += len(strings.Split(nextPart, ".")) + currType = fieldType + default: + return nil, fmt.Errorf("unable to parse one or more field values of %v", path) + } + } + + return &navigationSteps{steps, 0}, nil +} + +func (s *navigationSteps) pop() navigationStep { + if s.moreStepsRemaining() { + s.currentStepIndex++ + return s.steps[s.currentStepIndex-1] + } + return navigationStep{} +} + +func (s *navigationSteps) peek() navigationStep { + if s.moreStepsRemaining() { + return s.steps[s.currentStepIndex] + } + return navigationStep{} +} + +func (s *navigationSteps) moreStepsRemaining() bool { + return len(s.steps) > s.currentStepIndex +} + +// findNameStep takes the list of parts and a set of valid tags that can be used after the name. It then walks the list of parts +// until it find a valid "next" tag or until it reaches the end of the parts and then builds the name back up out of the individual parts +func findNameStep(parts []string, typeOptions sets.String) string { + if len(parts) == 0 { + return "" + } + + numberOfPartsInStep := findKnownValue(parts[1:], typeOptions) + 1 + // if we didn't find a known value, then the entire thing must be a name + if numberOfPartsInStep == 0 { + numberOfPartsInStep = len(parts) + } + nextParts := parts[0:numberOfPartsInStep] + + return strings.Join(nextParts, ".") +} + +// getPotentialTypeValues takes a type and looks up the tags used to represent its fields when serialized. +func getPotentialTypeValues(typeValue reflect.Type) (map[string]reflect.Type, error) { + if typeValue.Kind() == reflect.Ptr { + typeValue = typeValue.Elem() + } + + if typeValue.Kind() != reflect.Struct { + return nil, fmt.Errorf("%v is not of type struct", typeValue) + } + + ret := make(map[string]reflect.Type) + + for fieldIndex := 0; fieldIndex < typeValue.NumField(); fieldIndex++ { + fieldType := typeValue.Field(fieldIndex) + yamlTag := fieldType.Tag.Get("json") + yamlTagName := strings.Split(yamlTag, ",")[0] + + ret[yamlTagName] = fieldType.Type + } + + return ret, nil +} + +func findKnownValue(parts []string, valueOptions sets.String) int { + for i := range parts { + if valueOptions.Has(parts[i]) { + return i + } + } + + return -1 +} diff --git a/pkg/cmd/config/navigation_step_parser_test.go b/pkg/cmd/config/navigation_step_parser_test.go new file mode 100644 index 000000000..2ce0892cd --- /dev/null +++ b/pkg/cmd/config/navigation_step_parser_test.go @@ -0,0 +1,108 @@ +/* +Copyright 2014 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 config + +import ( + "reflect" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/util/diff" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type stepParserTest struct { + path string + expectedNavigationSteps navigationSteps + expectedError string +} + +func TestParseWithDots(t *testing.T) { + test := stepParserTest{ + path: "clusters.my.dot.delimited.name.server", + expectedNavigationSteps: navigationSteps{ + steps: []navigationStep{ + {"clusters", reflect.TypeOf(make(map[string]*clientcmdapi.Cluster))}, + {"my.dot.delimited.name", reflect.TypeOf(clientcmdapi.Cluster{})}, + {"server", reflect.TypeOf("")}, + }, + }, + } + + test.run(t) +} + +func TestParseWithDotsEndingWithName(t *testing.T) { + test := stepParserTest{ + path: "contexts.10.12.12.12", + expectedNavigationSteps: navigationSteps{ + steps: []navigationStep{ + {"contexts", reflect.TypeOf(make(map[string]*clientcmdapi.Context))}, + {"10.12.12.12", reflect.TypeOf(clientcmdapi.Context{})}, + }, + }, + } + + test.run(t) +} + +func TestParseWithBadValue(t *testing.T) { + test := stepParserTest{ + path: "user.bad", + expectedNavigationSteps: navigationSteps{ + steps: []navigationStep{}, + }, + expectedError: "unable to parse user.bad after [] at api.Config", + } + + test.run(t) +} + +func TestParseWithNoMatchingValue(t *testing.T) { + test := stepParserTest{ + path: "users.jheiss.exec.command", + expectedNavigationSteps: navigationSteps{ + steps: []navigationStep{}, + }, + expectedError: "unable to parse one or more field values of users.jheiss.exec", + } + + test.run(t) +} + +func (test stepParserTest) run(t *testing.T) { + actualSteps, err := newNavigationSteps(test.path) + if len(test.expectedError) != 0 { + if err == nil { + t.Errorf("Did not get %v", test.expectedError) + } else { + if !strings.Contains(err.Error(), test.expectedError) { + t.Errorf("Expected %v, but got %v", test.expectedError, err) + } + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !reflect.DeepEqual(test.expectedNavigationSteps, *actualSteps) { + t.Errorf("diff: %v", diff.ObjectDiff(test.expectedNavigationSteps, *actualSteps)) + t.Errorf("expected: %#v\n actual: %#v", test.expectedNavigationSteps, *actualSteps) + } +} diff --git a/pkg/cmd/config/rename_context.go b/pkg/cmd/config/rename_context.go new file mode 100644 index 000000000..8a64a3469 --- /dev/null +++ b/pkg/cmd/config/rename_context.go @@ -0,0 +1,132 @@ +/* +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 config + +import ( + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "k8s.io/client-go/tools/clientcmd" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/templates" +) + +// RenameContextOptions contains the options for running the rename-context cli command. +type RenameContextOptions struct { + configAccess clientcmd.ConfigAccess + contextName string + newName string +} + +const ( + renameContextUse = "rename-context CONTEXT_NAME NEW_NAME" + + renameContextShort = "Renames a context from the kubeconfig file." +) + +var ( + renameContextLong = templates.LongDesc(` + Renames a context from the kubeconfig file. + + CONTEXT_NAME is the context name that you wish to change. + + NEW_NAME is the new name you wish to set. + + Note: In case the context being renamed is the 'current-context', this field will also be updated.`) + + renameContextExample = templates.Examples(` + # Rename the context 'old-name' to 'new-name' in your kubeconfig file + kubectl config rename-context old-name new-name`) +) + +// NewCmdConfigRenameContext creates a command object for the "rename-context" action +func NewCmdConfigRenameContext(out io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + options := &RenameContextOptions{configAccess: configAccess} + + cmd := &cobra.Command{ + Use: renameContextUse, + DisableFlagsInUseLine: true, + Short: renameContextShort, + Long: renameContextLong, + Example: renameContextExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(cmd, args, out)) + cmdutil.CheckErr(options.Validate()) + cmdutil.CheckErr(options.RunRenameContext(out)) + }, + } + return cmd +} + +// Complete assigns RenameContextOptions from the args. +func (o *RenameContextOptions) Complete(cmd *cobra.Command, args []string, out io.Writer) error { + if len(args) != 2 { + return helpErrorf(cmd, "Unexpected args: %v", args) + } + + o.contextName = args[0] + o.newName = args[1] + return nil +} + +// Validate makes sure that provided values for command-line options are valid +func (o RenameContextOptions) Validate() error { + if len(o.newName) == 0 { + return errors.New("You must specify a new non-empty context name") + } + return nil +} + +// RunRenameContext performs the execution for 'config rename-context' sub command +func (o RenameContextOptions) RunRenameContext(out io.Writer) error { + config, err := o.configAccess.GetStartingConfig() + if err != nil { + return err + } + + configFile := o.configAccess.GetDefaultFilename() + if o.configAccess.IsExplicitFile() { + configFile = o.configAccess.GetExplicitFile() + } + + context, exists := config.Contexts[o.contextName] + if !exists { + return fmt.Errorf("cannot rename the context %q, it's not in %s", o.contextName, configFile) + } + + _, newExists := config.Contexts[o.newName] + if newExists { + return fmt.Errorf("cannot rename the context %q, the context %q already exists in %s", o.contextName, o.newName, configFile) + } + + config.Contexts[o.newName] = context + delete(config.Contexts, o.contextName) + + if config.CurrentContext == o.contextName { + config.CurrentContext = o.newName + } + + if err := clientcmd.ModifyConfig(o.configAccess, *config, true); err != nil { + return err + } + + fmt.Fprintf(out, "Context %q renamed to %q.\n", o.contextName, o.newName) + return nil +} diff --git a/pkg/cmd/config/rename_context_test.go b/pkg/cmd/config/rename_context_test.go new file mode 100644 index 000000000..01edde9fe --- /dev/null +++ b/pkg/cmd/config/rename_context_test.go @@ -0,0 +1,156 @@ +/* +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 config + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "strings" + "testing" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +const ( + currentContext = "current-context" + newContext = "new-context" + nonexistentCurrentContext = "nonexistent-current-context" + existentNewContext = "existent-new-context" +) + +var ( + contextData = clientcmdapi.NewContext() +) + +type renameContextTest struct { + description string + initialConfig clientcmdapi.Config // initial config + expectedConfig clientcmdapi.Config // expected config + args []string // kubectl rename-context args + expectedOut string // expected out message + expectedErr string // expected error message +} + +func TestRenameContext(t *testing.T) { + initialConfig := clientcmdapi.Config{ + CurrentContext: currentContext, + Contexts: map[string]*clientcmdapi.Context{currentContext: contextData}} + + expectedConfig := clientcmdapi.Config{ + CurrentContext: newContext, + Contexts: map[string]*clientcmdapi.Context{newContext: contextData}} + + test := renameContextTest{ + description: "Testing for kubectl config rename-context whose context to be renamed is the CurrentContext", + initialConfig: initialConfig, + expectedConfig: expectedConfig, + args: []string{currentContext, newContext}, + expectedOut: fmt.Sprintf("Context %q renamed to %q.\n", currentContext, newContext), + expectedErr: "", + } + test.run(t) +} + +func TestRenameNonexistentContext(t *testing.T) { + initialConfig := clientcmdapi.Config{ + CurrentContext: currentContext, + Contexts: map[string]*clientcmdapi.Context{currentContext: contextData}} + + test := renameContextTest{ + description: "Testing for kubectl config rename-context whose context to be renamed no exists", + initialConfig: initialConfig, + expectedConfig: initialConfig, + args: []string{nonexistentCurrentContext, newContext}, + expectedOut: "", + expectedErr: fmt.Sprintf("cannot rename the context %q, it's not in", nonexistentCurrentContext), + } + test.run(t) +} + +func TestRenameToAlreadyExistingContext(t *testing.T) { + initialConfig := clientcmdapi.Config{ + CurrentContext: currentContext, + Contexts: map[string]*clientcmdapi.Context{ + currentContext: contextData, + existentNewContext: contextData}} + + test := renameContextTest{ + description: "Testing for kubectl config rename-context whose the new name is already in another context.", + initialConfig: initialConfig, + expectedConfig: initialConfig, + args: []string{currentContext, existentNewContext}, + expectedOut: "", + expectedErr: fmt.Sprintf("cannot rename the context %q, the context %q already exists", currentContext, existentNewContext), + } + test.run(t) +} + +func (test renameContextTest) run(t *testing.T) { + fakeKubeFile, _ := ioutil.TempFile("", "") + defer os.Remove(fakeKubeFile.Name()) + err := clientcmd.WriteToFile(test.initialConfig, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + options := RenameContextOptions{ + configAccess: pathOptions, + contextName: test.args[0], + newName: test.args[1], + } + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdConfigRenameContext(buf, options.configAccess) + + options.Complete(cmd, test.args, buf) + options.Validate() + err = options.RunRenameContext(buf) + + if len(test.expectedErr) != 0 { + if err == nil { + t.Errorf("Did not get %v", test.expectedErr) + } else { + if !strings.Contains(err.Error(), test.expectedErr) { + t.Errorf("Expected error %v, but got %v", test.expectedErr, err) + } + } + return + } + + config, err := clientcmd.LoadFromFile(fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error loading kubeconfig file: %v", err) + } + + _, oldExists := config.Contexts[currentContext] + _, newExists := config.Contexts[newContext] + + if (!newExists) || (oldExists) || (config.CurrentContext != newContext) { + t.Errorf("Failed in: %q\n expected %v\n but got %v", test.description, test.expectedConfig, *config) + } + + if len(test.expectedOut) != 0 { + if buf.String() != test.expectedOut { + t.Errorf("Failed in:%q\n expected out %v\n but got %v", test.description, test.expectedOut, buf.String()) + } + } +} diff --git a/pkg/cmd/config/set.go b/pkg/cmd/config/set.go new file mode 100644 index 000000000..9e9fcd869 --- /dev/null +++ b/pkg/cmd/config/set.go @@ -0,0 +1,262 @@ +/* +Copyright 2014 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 config + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "reflect" + "strings" + + "github.com/spf13/cobra" + + "k8s.io/client-go/tools/clientcmd" + cliflag "k8s.io/component-base/cli/flag" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +type setOptions struct { + configAccess clientcmd.ConfigAccess + propertyName string + propertyValue string + setRawBytes cliflag.Tristate +} + +var ( + setLong = templates.LongDesc(` + Sets an individual value in a kubeconfig file + + PROPERTY_NAME is a dot delimited name where each token represents either an attribute name or a map key. Map keys may not contain dots. + + PROPERTY_VALUE is the new value you wish to set. Binary fields such as 'certificate-authority-data' expect a base64 encoded string unless the --set-raw-bytes flag is used. + + Specifying a attribute name that already exists will merge new fields on top of existing values.`) + + setExample = templates.Examples(` + # Set server field on the my-cluster cluster to https://1.2.3.4 + kubectl config set clusters.my-cluster.server https://1.2.3.4 + + # Set certificate-authority-data field on the my-cluster cluster. + kubectl config set clusters.my-cluster.certificate-authority-data $(echo "cert_data_here" | base64 -i -) + + # Set cluster field in the my-context context to my-cluster. + kubectl config set contexts.my-context.cluster my-cluster + + # Set client-key-data field in the cluster-admin user using --set-raw-bytes option. + kubectl config set users.cluster-admin.client-key-data cert_data_here --set-raw-bytes=true`) +) + +// NewCmdConfigSet returns a Command instance for 'config set' sub command +func NewCmdConfigSet(out io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + options := &setOptions{configAccess: configAccess} + + cmd := &cobra.Command{ + Use: "set PROPERTY_NAME PROPERTY_VALUE", + DisableFlagsInUseLine: true, + Short: i18n.T("Sets an individual value in a kubeconfig file"), + Long: setLong, + Example: setExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.complete(cmd)) + cmdutil.CheckErr(options.run()) + fmt.Fprintf(out, "Property %q set.\n", options.propertyName) + }, + } + + f := cmd.Flags().VarPF(&options.setRawBytes, "set-raw-bytes", "", "When writing a []byte PROPERTY_VALUE, write the given string directly without base64 decoding.") + f.NoOptDefVal = "true" + return cmd +} + +func (o setOptions) run() error { + err := o.validate() + if err != nil { + return err + } + + config, err := o.configAccess.GetStartingConfig() + if err != nil { + return err + } + steps, err := newNavigationSteps(o.propertyName) + if err != nil { + return err + } + + setRawBytes := false + if o.setRawBytes.Provided() { + setRawBytes = o.setRawBytes.Value() + } + + err = modifyConfig(reflect.ValueOf(config), steps, o.propertyValue, false, setRawBytes) + if err != nil { + return err + } + + if err := clientcmd.ModifyConfig(o.configAccess, *config, false); err != nil { + return err + } + + return nil +} + +func (o *setOptions) complete(cmd *cobra.Command) error { + endingArgs := cmd.Flags().Args() + if len(endingArgs) != 2 { + return helpErrorf(cmd, "Unexpected args: %v", endingArgs) + } + + o.propertyValue = endingArgs[1] + o.propertyName = endingArgs[0] + return nil +} + +func (o setOptions) validate() error { + if len(o.propertyValue) == 0 { + return errors.New("you cannot use set to unset a property") + } + + if len(o.propertyName) == 0 { + return errors.New("you must specify a property") + } + + return nil +} + +func modifyConfig(curr reflect.Value, steps *navigationSteps, propertyValue string, unset bool, setRawBytes bool) error { + currStep := steps.pop() + + actualCurrValue := curr + if curr.Kind() == reflect.Ptr { + actualCurrValue = curr.Elem() + } + + switch actualCurrValue.Kind() { + case reflect.Map: + if !steps.moreStepsRemaining() && !unset { + return fmt.Errorf("can't set a map to a value: %v", actualCurrValue) + } + + mapKey := reflect.ValueOf(currStep.stepValue) + mapValueType := curr.Type().Elem().Elem() + + if !steps.moreStepsRemaining() && unset { + actualCurrValue.SetMapIndex(mapKey, reflect.Value{}) + return nil + } + + currMapValue := actualCurrValue.MapIndex(mapKey) + + needToSetNewMapValue := currMapValue.Kind() == reflect.Invalid + if needToSetNewMapValue { + if unset { + return fmt.Errorf("current map key `%v` is invalid", mapKey.Interface()) + } + currMapValue = reflect.New(mapValueType.Elem()).Elem().Addr() + actualCurrValue.SetMapIndex(mapKey, currMapValue) + } + + err := modifyConfig(currMapValue, steps, propertyValue, unset, setRawBytes) + if err != nil { + return err + } + + return nil + + case reflect.String: + if steps.moreStepsRemaining() { + return fmt.Errorf("can't have more steps after a string. %v", steps) + } + actualCurrValue.SetString(propertyValue) + return nil + + case reflect.Slice: + if steps.moreStepsRemaining() { + return fmt.Errorf("can't have more steps after bytes. %v", steps) + } + innerKind := actualCurrValue.Type().Elem().Kind() + if innerKind != reflect.Uint8 { + return fmt.Errorf("unrecognized slice type. %v", innerKind) + } + + if unset { + actualCurrValue.Set(reflect.Zero(actualCurrValue.Type())) + return nil + } + + if setRawBytes { + actualCurrValue.SetBytes([]byte(propertyValue)) + } else { + val, err := base64.StdEncoding.DecodeString(propertyValue) + if err != nil { + return fmt.Errorf("error decoding input value: %v", err) + } + actualCurrValue.SetBytes(val) + } + return nil + + case reflect.Bool: + if steps.moreStepsRemaining() { + return fmt.Errorf("can't have more steps after a bool. %v", steps) + } + boolValue, err := toBool(propertyValue) + if err != nil { + return err + } + actualCurrValue.SetBool(boolValue) + return nil + + case reflect.Struct: + for fieldIndex := 0; fieldIndex < actualCurrValue.NumField(); fieldIndex++ { + currFieldValue := actualCurrValue.Field(fieldIndex) + currFieldType := actualCurrValue.Type().Field(fieldIndex) + currYamlTag := currFieldType.Tag.Get("json") + currFieldTypeYamlName := strings.Split(currYamlTag, ",")[0] + + if currFieldTypeYamlName == currStep.stepValue { + thisMapHasNoValue := (currFieldValue.Kind() == reflect.Map && currFieldValue.IsNil()) + + if thisMapHasNoValue { + newValue := reflect.MakeMap(currFieldValue.Type()) + currFieldValue.Set(newValue) + + if !steps.moreStepsRemaining() && unset { + return nil + } + } + + if !steps.moreStepsRemaining() && unset { + // if we're supposed to unset the value or if the value is a map that doesn't exist, create a new value and overwrite + newValue := reflect.New(currFieldValue.Type()).Elem() + currFieldValue.Set(newValue) + return nil + } + + return modifyConfig(currFieldValue.Addr(), steps, propertyValue, unset, setRawBytes) + } + } + + return fmt.Errorf("unable to locate path %#v under %v", currStep, actualCurrValue) + + } + + panic(fmt.Errorf("unrecognized type: %v", actualCurrValue)) +} diff --git a/pkg/cmd/config/set_test.go b/pkg/cmd/config/set_test.go new file mode 100644 index 000000000..3fa886d1c --- /dev/null +++ b/pkg/cmd/config/set_test.go @@ -0,0 +1,88 @@ +/* +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 config + +import ( + "bytes" + "io/ioutil" + "os" + "testing" + + "reflect" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type setConfigTest struct { + description string + config clientcmdapi.Config + args []string + expected string + expectedConfig clientcmdapi.Config +} + +func TestSetConfigCurrentContext(t *testing.T) { + conf := clientcmdapi.Config{ + Kind: "Config", + APIVersion: "v1", + CurrentContext: "minikube", + } + expectedConfig := *clientcmdapi.NewConfig() + expectedConfig.CurrentContext = "my-cluster" + test := setConfigTest{ + description: "Testing for kubectl config set current-context", + config: conf, + args: []string{"current-context", "my-cluster"}, + expected: `Property "current-context" set.` + "\n", + expectedConfig: expectedConfig, + } + test.run(t) +} + +func (test setConfigTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.config, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdConfigSet(buf, pathOptions) + cmd.SetArgs(test.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error executing command: %v", err) + } + config, err := clientcmd.LoadFromFile(fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error loading kubeconfig file: %v", err) + } + if len(test.expected) != 0 { + if buf.String() != test.expected { + t.Errorf("Failed in:%q\n expected %v\n but got %v", test.description, test.expected, buf.String()) + } + } + if !reflect.DeepEqual(*config, test.expectedConfig) { + t.Errorf("Failed in: %q\n expected %v\n but got %v", test.description, *config, test.expectedConfig) + } +} diff --git a/pkg/cmd/config/unset.go b/pkg/cmd/config/unset.go new file mode 100644 index 000000000..6af73d1c4 --- /dev/null +++ b/pkg/cmd/config/unset.go @@ -0,0 +1,116 @@ +/* +Copyright 2014 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 config + +import ( + "errors" + "fmt" + "io" + "reflect" + + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "k8s.io/client-go/tools/clientcmd" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" +) + +type unsetOptions struct { + configAccess clientcmd.ConfigAccess + propertyName string +} + +var ( + unsetLong = templates.LongDesc(` + Unsets an individual value in a kubeconfig file + + PROPERTY_NAME is a dot delimited name where each token represents either an attribute name or a map key. Map keys may not contain dots.`) + + unsetExample = templates.Examples(` + # Unset the current-context. + kubectl config unset current-context + + # Unset namespace in foo context. + kubectl config unset contexts.foo.namespace`) +) + +// NewCmdConfigUnset returns a Command instance for 'config unset' sub command +func NewCmdConfigUnset(out io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + options := &unsetOptions{configAccess: configAccess} + + cmd := &cobra.Command{ + Use: "unset PROPERTY_NAME", + DisableFlagsInUseLine: true, + Short: i18n.T("Unsets an individual value in a kubeconfig file"), + Long: unsetLong, + Example: unsetExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.complete(cmd, args)) + cmdutil.CheckErr(options.run(out)) + + }, + } + + return cmd +} + +func (o unsetOptions) run(out io.Writer) error { + err := o.validate() + if err != nil { + return err + } + + config, err := o.configAccess.GetStartingConfig() + if err != nil { + return err + } + + steps, err := newNavigationSteps(o.propertyName) + if err != nil { + return err + } + err = modifyConfig(reflect.ValueOf(config), steps, "", true, true) + if err != nil { + return err + } + + if err := clientcmd.ModifyConfig(o.configAccess, *config, false); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Property %q unset.\n", o.propertyName); err != nil { + return err + } + return nil +} + +func (o *unsetOptions) complete(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return helpErrorf(cmd, "Unexpected args: %v", args) + } + + o.propertyName = args[0] + return nil +} + +func (o unsetOptions) validate() error { + if len(o.propertyName) == 0 { + return errors.New("you must specify a property") + } + + return nil +} diff --git a/pkg/cmd/config/unset_test.go b/pkg/cmd/config/unset_test.go new file mode 100644 index 000000000..ea282a4d9 --- /dev/null +++ b/pkg/cmd/config/unset_test.go @@ -0,0 +1,150 @@ +/* +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 config + +import ( + "bytes" + "io/ioutil" + "os" + "testing" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type unsetConfigTest struct { + description string + config clientcmdapi.Config + args []string + expected string + expectedErr string +} + +func TestUnsetConfigString(t *testing.T) { + conf := clientcmdapi.Config{ + Kind: "Config", + APIVersion: "v1", + Clusters: map[string]*clientcmdapi.Cluster{ + "minikube": {Server: "https://192.168.99.100:8443"}, + "my-cluster": {Server: "https://192.168.0.1:3434"}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "minikube": {AuthInfo: "minikube", Cluster: "minikube"}, + "my-cluster": {AuthInfo: "mu-cluster", Cluster: "my-cluster"}, + }, + CurrentContext: "minikube", + } + test := unsetConfigTest{ + description: "Testing for kubectl config unset a value", + config: conf, + args: []string{"current-context"}, + expected: `Property "current-context" unset.` + "\n", + } + test.run(t) +} + +func TestUnsetConfigMap(t *testing.T) { + conf := clientcmdapi.Config{ + Kind: "Config", + APIVersion: "v1", + Clusters: map[string]*clientcmdapi.Cluster{ + "minikube": {Server: "https://192.168.99.100:8443"}, + "my-cluster": {Server: "https://192.168.0.1:3434"}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "minikube": {AuthInfo: "minikube", Cluster: "minikube"}, + "my-cluster": {AuthInfo: "mu-cluster", Cluster: "my-cluster"}, + }, + CurrentContext: "minikube", + } + test := unsetConfigTest{ + description: "Testing for kubectl config unset a map", + config: conf, + args: []string{"clusters"}, + expected: `Property "clusters" unset.` + "\n", + } + test.run(t) +} + +func TestUnsetUnexistConfig(t *testing.T) { + conf := clientcmdapi.Config{ + Kind: "Config", + APIVersion: "v1", + Clusters: map[string]*clientcmdapi.Cluster{ + "minikube": {Server: "https://192.168.99.100:8443"}, + "my-cluster": {Server: "https://192.168.0.1:3434"}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "minikube": {AuthInfo: "minikube", Cluster: "minikube"}, + "my-cluster": {AuthInfo: "mu-cluster", Cluster: "my-cluster"}, + }, + CurrentContext: "minikube", + } + + test := unsetConfigTest{ + description: "Testing for kubectl config unset a unexist map key", + config: conf, + args: []string{"contexts.foo.namespace"}, + expectedErr: "current map key `foo` is invalid", + } + test.run(t) + +} + +func (test unsetConfigTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile(os.TempDir(), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.config, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdConfigUnset(buf, pathOptions) + opts := &unsetOptions{configAccess: pathOptions} + err = opts.complete(cmd, test.args) + if err == nil { + err = opts.run(buf) + } + config, err := clientcmd.LoadFromFile(fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error loading kubeconfig file: %v", err) + } + + if err != nil && err.Error() != test.expectedErr { + t.Fatalf("expected error:\n %v\nbut got error:\n%v", test.expectedErr, err) + } + if len(test.expected) != 0 { + if buf.String() != test.expected { + t.Errorf("Failed in :%q\n expected %v\n but got %v", test.description, test.expected, buf.String()) + } + } + if test.args[0] == "current-context" { + if config.CurrentContext != "" { + t.Errorf("Failed in :%q\n expected current-context nil,but got %v", test.description, config.CurrentContext) + } + } else if test.args[0] == "clusters" { + if len(config.Clusters) != 0 { + t.Errorf("Failed in :%q\n expected clusters nil map, but got %v", test.description, config.Clusters) + } + } +} diff --git a/pkg/cmd/config/use_context.go b/pkg/cmd/config/use_context.go new file mode 100644 index 000000000..c87dd5f7d --- /dev/null +++ b/pkg/cmd/config/use_context.go @@ -0,0 +1,103 @@ +/* +Copyright 2014 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 config + +import ( + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + useContextExample = templates.Examples(` + # Use the context for the minikube cluster + kubectl config use-context minikube`) +) + +type useContextOptions struct { + configAccess clientcmd.ConfigAccess + contextName string +} + +// NewCmdConfigUseContext returns a Command instance for 'config use-context' sub command +func NewCmdConfigUseContext(out io.Writer, configAccess clientcmd.ConfigAccess) *cobra.Command { + options := &useContextOptions{configAccess: configAccess} + + cmd := &cobra.Command{ + Use: "use-context CONTEXT_NAME", + DisableFlagsInUseLine: true, + Short: i18n.T("Sets the current-context in a kubeconfig file"), + Aliases: []string{"use"}, + Long: `Sets the current-context in a kubeconfig file`, + Example: useContextExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.complete(cmd)) + cmdutil.CheckErr(options.run()) + fmt.Fprintf(out, "Switched to context %q.\n", options.contextName) + }, + } + + return cmd +} + +func (o useContextOptions) run() error { + config, err := o.configAccess.GetStartingConfig() + if err != nil { + return err + } + + err = o.validate(config) + if err != nil { + return err + } + + config.CurrentContext = o.contextName + + return clientcmd.ModifyConfig(o.configAccess, *config, true) +} + +func (o *useContextOptions) complete(cmd *cobra.Command) error { + endingArgs := cmd.Flags().Args() + if len(endingArgs) != 1 { + return helpErrorf(cmd, "Unexpected args: %v", endingArgs) + } + + o.contextName = endingArgs[0] + return nil +} + +func (o useContextOptions) validate(config *clientcmdapi.Config) error { + if len(o.contextName) == 0 { + return errors.New("empty context names are not allowed") + } + + for name := range config.Contexts { + if name == o.contextName { + return nil + } + } + + return fmt.Errorf("no context exists with the name: %q", o.contextName) +} diff --git a/pkg/cmd/config/use_context_test.go b/pkg/cmd/config/use_context_test.go new file mode 100644 index 000000000..74424bc51 --- /dev/null +++ b/pkg/cmd/config/use_context_test.go @@ -0,0 +1,104 @@ +/* +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 config + +import ( + "bytes" + "io/ioutil" + "os" + "testing" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type useContextTest struct { + description string + config clientcmdapi.Config //initiate kubectl config + args []string //kubectl config use-context args + expected string //expect out + expectedConfig clientcmdapi.Config //expect kubectl config +} + +func TestUseContext(t *testing.T) { + conf := clientcmdapi.Config{ + Kind: "Config", + APIVersion: "v1", + Clusters: map[string]*clientcmdapi.Cluster{ + "minikube": {Server: "https://192.168.99.100:8443"}, + "my-cluster": {Server: "https://192.168.0.1:3434"}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "minikube": {AuthInfo: "minikube", Cluster: "minikube"}, + "my-cluster": {AuthInfo: "mu-cluster", Cluster: "my-cluster"}, + }, + CurrentContext: "minikube", + } + test := useContextTest{ + description: "Testing for kubectl config use-context", + config: conf, + args: []string{"my-cluster"}, + expected: `Switched to context "my-cluster".` + "\n", + expectedConfig: clientcmdapi.Config{ + Kind: "Config", + APIVersion: "v1", + Clusters: map[string]*clientcmdapi.Cluster{ + "minikube": {Server: "https://192.168.99.100:8443"}, + "my-cluster": {Server: "https://192.168.0.1:3434"}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "minikube": {AuthInfo: "minikube", Cluster: "minikube"}, + "my-cluster": {AuthInfo: "mu-cluster", Cluster: "my-cluster"}, + }, + CurrentContext: "my-cluster", + }, + } + test.run(t) +} + +func (test useContextTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile(os.TempDir(), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.config, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdConfigUseContext(buf, pathOptions) + cmd.SetArgs(test.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error executing command: %v,kubectl config use-context args: %v", err, test.args) + } + config, err := clientcmd.LoadFromFile(fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error loading kubeconfig file: %v", err) + } + if len(test.expected) != 0 { + if buf.String() != test.expected { + t.Errorf("Failed in :%q\n expected %v\n, but got %v\n", test.description, test.expected, buf.String()) + } + } + if test.expectedConfig.CurrentContext != config.CurrentContext { + t.Errorf("Failed in :%q\n expected config %v, but found %v\n in kubeconfig\n", test.description, test.expectedConfig, config) + } +} diff --git a/pkg/cmd/config/view.go b/pkg/cmd/config/view.go new file mode 100644 index 000000000..85aef2d43 --- /dev/null +++ b/pkg/cmd/config/view.go @@ -0,0 +1,185 @@ +/* +Copyright 2014 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 config + +import ( + "errors" + + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "k8s.io/client-go/tools/clientcmd/api/latest" + cliflag "k8s.io/component-base/cli/flag" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// ViewOptions holds the command-line options for 'config view' sub command +type ViewOptions struct { + PrintFlags *genericclioptions.PrintFlags + PrintObject printers.ResourcePrinterFunc + + ConfigAccess clientcmd.ConfigAccess + Merge cliflag.Tristate + Flatten bool + Minify bool + RawByteData bool + + Context string + OutputFormat string + + genericclioptions.IOStreams +} + +var ( + viewLong = templates.LongDesc(` + Display merged kubeconfig settings or a specified kubeconfig file. + + You can use --output jsonpath={...} to extract specific values using a jsonpath expression.`) + + viewExample = templates.Examples(` + # Show merged kubeconfig settings. + kubectl config view + + # Show merged kubeconfig settings and raw certificate data. + kubectl config view --raw + + # Get the password for the e2e user + kubectl config view -o jsonpath='{.users[?(@.name == "e2e")].user.password}'`) + + defaultOutputFormat = "yaml" +) + +// NewCmdConfigView returns a Command instance for 'config view' sub command +func NewCmdConfigView(f cmdutil.Factory, streams genericclioptions.IOStreams, ConfigAccess clientcmd.ConfigAccess) *cobra.Command { + o := &ViewOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithTypeSetter(scheme.Scheme).WithDefaultOutput("yaml"), + ConfigAccess: ConfigAccess, + + IOStreams: streams, + } + + cmd := &cobra.Command{ + Use: "view", + Short: i18n.T("Display merged kubeconfig settings or a specified kubeconfig file"), + Long: viewLong, + Example: viewExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run()) + }, + } + + o.PrintFlags.AddFlags(cmd) + + o.Merge.Default(true) + mergeFlag := cmd.Flags().VarPF(&o.Merge, "merge", "", "Merge the full hierarchy of kubeconfig files") + mergeFlag.NoOptDefVal = "true" + cmd.Flags().BoolVar(&o.RawByteData, "raw", o.RawByteData, "Display raw byte data") + cmd.Flags().BoolVar(&o.Flatten, "flatten", o.Flatten, "Flatten the resulting kubeconfig file into self-contained output (useful for creating portable kubeconfig files)") + cmd.Flags().BoolVar(&o.Minify, "minify", o.Minify, "Remove all information not used by current-context from the output") + return cmd +} + +// Complete completes the required command-line options +func (o *ViewOptions) Complete(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "unexpected arguments: %v", args) + } + if o.ConfigAccess.IsExplicitFile() { + if !o.Merge.Provided() { + o.Merge.Set("false") + } + } + + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObject = printer.PrintObj + o.Context = cmdutil.GetFlagString(cmd, "context") + + return nil +} + +// Validate makes sure that provided values for command-line options are valid +func (o ViewOptions) Validate() error { + if !o.Merge.Value() && !o.ConfigAccess.IsExplicitFile() { + return errors.New("if merge==false a precise file must to specified") + } + + return nil +} + +// Run performs the execution of 'config view' sub command +func (o ViewOptions) Run() error { + config, err := o.loadConfig() + if err != nil { + return err + } + + if o.Minify { + if len(o.Context) > 0 { + config.CurrentContext = o.Context + } + if err := clientcmdapi.MinifyConfig(config); err != nil { + return err + } + } + + if o.Flatten { + if err := clientcmdapi.FlattenConfig(config); err != nil { + return err + } + } else if !o.RawByteData { + clientcmdapi.ShortenConfig(config) + } + + convertedObj, err := latest.Scheme.ConvertToVersion(config, latest.ExternalVersion) + if err != nil { + return err + } + + return o.PrintObject(convertedObj, o.Out) +} + +func (o ViewOptions) loadConfig() (*clientcmdapi.Config, error) { + err := o.Validate() + if err != nil { + return nil, err + } + + config, err := o.getStartingConfig() + return config, err +} + +// getStartingConfig returns the Config object built from the sources specified by the options, the filename read (only if it was a single file), and an error if something goes wrong +func (o *ViewOptions) getStartingConfig() (*clientcmdapi.Config, error) { + switch { + case !o.Merge.Value(): + return clientcmd.LoadFromFile(o.ConfigAccess.GetExplicitFile()) + + default: + return o.ConfigAccess.GetStartingConfig() + } +} diff --git a/pkg/cmd/config/view_test.go b/pkg/cmd/config/view_test.go new file mode 100644 index 000000000..59f69cd56 --- /dev/null +++ b/pkg/cmd/config/view_test.go @@ -0,0 +1,201 @@ +/* +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 config + +import ( + "io/ioutil" + "os" + "testing" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +type viewClusterTest struct { + description string + config clientcmdapi.Config //initiate kubectl config + flags []string //kubectl config viw flags + expected string //expect out +} + +func TestViewCluster(t *testing.T) { + conf := clientcmdapi.Config{ + Kind: "Config", + APIVersion: "v1", + Clusters: map[string]*clientcmdapi.Cluster{ + "minikube": {Server: "https://192.168.99.100:8443"}, + "my-cluster": {Server: "https://192.168.0.1:3434"}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "minikube": {AuthInfo: "minikube", Cluster: "minikube"}, + "my-cluster": {AuthInfo: "mu-cluster", Cluster: "my-cluster"}, + }, + CurrentContext: "minikube", + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "minikube": {Token: "minikube-token"}, + "mu-cluster": {Token: "minikube-token"}, + }, + } + + test := viewClusterTest{ + description: "Testing for kubectl config view", + config: conf, + expected: `apiVersion: v1 +clusters: +- cluster: + server: https://192.168.99.100:8443 + name: minikube +- cluster: + server: https://192.168.0.1:3434 + name: my-cluster +contexts: +- context: + cluster: minikube + user: minikube + name: minikube +- context: + cluster: my-cluster + user: mu-cluster + name: my-cluster +current-context: minikube +kind: Config +preferences: {} +users: +- name: minikube + user: + token: minikube-token +- name: mu-cluster + user: + token: minikube-token` + "\n", + } + + test.run(t) + +} + +func TestViewClusterMinify(t *testing.T) { + conf := clientcmdapi.Config{ + Kind: "Config", + APIVersion: "v1", + Clusters: map[string]*clientcmdapi.Cluster{ + "minikube": {Server: "https://192.168.99.100:8443"}, + "my-cluster": {Server: "https://192.168.0.1:3434"}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "minikube": {AuthInfo: "minikube", Cluster: "minikube"}, + "my-cluster": {AuthInfo: "mu-cluster", Cluster: "my-cluster"}, + }, + CurrentContext: "minikube", + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "minikube": {Token: "minikube-token"}, + "mu-cluster": {Token: "minikube-token"}, + }, + } + + testCases := []struct { + description string + config clientcmdapi.Config + flags []string + expected string + }{ + { + description: "Testing for kubectl config view --minify=true", + config: conf, + flags: []string{"--minify=true"}, + expected: `apiVersion: v1 +clusters: +- cluster: + server: https://192.168.99.100:8443 + name: minikube +contexts: +- context: + cluster: minikube + user: minikube + name: minikube +current-context: minikube +kind: Config +preferences: {} +users: +- name: minikube + user: + token: minikube-token` + "\n", + }, + { + description: "Testing for kubectl config view --minify=true --context=my-cluster", + config: conf, + flags: []string{"--minify=true", "--context=my-cluster"}, + expected: `apiVersion: v1 +clusters: +- cluster: + server: https://192.168.0.1:3434 + name: my-cluster +contexts: +- context: + cluster: my-cluster + user: mu-cluster + name: my-cluster +current-context: my-cluster +kind: Config +preferences: {} +users: +- name: mu-cluster + user: + token: minikube-token` + "\n", + }, + } + + for _, test := range testCases { + cmdTest := viewClusterTest{ + description: test.description, + config: test.config, + flags: test.flags, + expected: test.expected, + } + cmdTest.run(t) + } +} + +func (test viewClusterTest) run(t *testing.T) { + fakeKubeFile, err := ioutil.TempFile(os.TempDir(), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer os.Remove(fakeKubeFile.Name()) + err = clientcmd.WriteToFile(test.config, fakeKubeFile.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + pathOptions := clientcmd.NewDefaultPathOptions() + pathOptions.GlobalFile = fakeKubeFile.Name() + pathOptions.EnvVar = "" + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdConfigView(cmdutil.NewFactory(genericclioptions.NewTestConfigFlags()), streams, pathOptions) + // "context" is a global flag, inherited from base kubectl command in the real world + cmd.Flags().String("context", "", "The name of the kubeconfig context to use") + cmd.Flags().Parse(test.flags) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error executing command: %v,kubectl config view flags: %v", err, test.flags) + } + if len(test.expected) != 0 { + if buf.String() != test.expected { + t.Errorf("Failed in %q\n expected %v\n but got %v\n", test.description, test.expected, buf.String()) + } + } +} diff --git a/pkg/cmd/cp/cp.go b/pkg/cmd/cp/cp.go new file mode 100644 index 000000000..8f315fbfb --- /dev/null +++ b/pkg/cmd/cp/cp.go @@ -0,0 +1,540 @@ +/* +Copyright 2016 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 cp + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "path" + "path/filepath" + "strings" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubernetes/pkg/kubectl/cmd/exec" + + "bytes" + + "github.com/lithammer/dedent" + "github.com/spf13/cobra" +) + +var ( + cpExample = templates.Examples(i18n.T(` + # !!!Important Note!!! + # Requires that the 'tar' binary is present in your container + # image. If 'tar' is not present, 'kubectl cp' will fail. + + # Copy /tmp/foo_dir local directory to /tmp/bar_dir in a remote pod in the default namespace + kubectl cp /tmp/foo_dir :/tmp/bar_dir + + # Copy /tmp/foo local file to /tmp/bar in a remote pod in a specific container + kubectl cp /tmp/foo :/tmp/bar -c + + # Copy /tmp/foo local file to /tmp/bar in a remote pod in namespace + kubectl cp /tmp/foo /:/tmp/bar + + # Copy /tmp/foo from a remote pod to /tmp/bar locally + kubectl cp /:/tmp/foo /tmp/bar`)) + + cpUsageStr = dedent.Dedent(` + expected 'cp [-c container]'. + is: + [namespace/]pod-name:/file/path for a remote file + /file/path for a local file`) +) + +// CopyOptions have the data required to perform the copy operation +type CopyOptions struct { + Container string + Namespace string + NoPreserve bool + + ClientConfig *restclient.Config + Clientset kubernetes.Interface + + genericclioptions.IOStreams +} + +// NewCopyOptions creates the options for copy +func NewCopyOptions(ioStreams genericclioptions.IOStreams) *CopyOptions { + return &CopyOptions{ + IOStreams: ioStreams, + } +} + +// NewCmdCp creates a new Copy command. +func NewCmdCp(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewCopyOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "cp ", + DisableFlagsInUseLine: true, + Short: i18n.T("Copy files and directories to and from containers."), + Long: "Copy files and directories to and from containers.", + Example: cpExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(o.Run(args)) + }, + } + cmd.Flags().StringVarP(&o.Container, "container", "c", o.Container, "Container name. If omitted, the first container in the pod will be chosen") + cmd.Flags().BoolVarP(&o.NoPreserve, "no-preserve", "", false, "The copied file/directory's ownership and permissions will not be preserved in the container") + + return cmd +} + +type fileSpec struct { + PodNamespace string + PodName string + File string +} + +var ( + errFileSpecDoesntMatchFormat = errors.New("Filespec must match the canonical format: [[namespace/]pod:]file/path") + errFileCannotBeEmpty = errors.New("Filepath can not be empty") +) + +func extractFileSpec(arg string) (fileSpec, error) { + if i := strings.Index(arg, ":"); i == -1 { + return fileSpec{File: arg}, nil + } else if i > 0 { + file := arg[i+1:] + pod := arg[:i] + pieces := strings.Split(pod, "/") + if len(pieces) == 1 { + return fileSpec{ + PodName: pieces[0], + File: file, + }, nil + } + if len(pieces) == 2 { + return fileSpec{ + PodNamespace: pieces[0], + PodName: pieces[1], + File: file, + }, nil + } + } + + return fileSpec{}, errFileSpecDoesntMatchFormat +} + +// Complete completes all the required options +func (o *CopyOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + var err error + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.Clientset, err = f.KubernetesClientSet() + if err != nil { + return err + } + + o.ClientConfig, err = f.ToRESTConfig() + if err != nil { + return err + } + return nil +} + +// Validate makes sure provided values for CopyOptions are valid +func (o *CopyOptions) Validate(cmd *cobra.Command, args []string) error { + if len(args) != 2 { + return cmdutil.UsageErrorf(cmd, cpUsageStr) + } + return nil +} + +// Run performs the execution +func (o *CopyOptions) Run(args []string) error { + if len(args) < 2 { + return fmt.Errorf("source and destination are required") + } + srcSpec, err := extractFileSpec(args[0]) + if err != nil { + return err + } + destSpec, err := extractFileSpec(args[1]) + if err != nil { + return err + } + + if len(srcSpec.PodName) != 0 && len(destSpec.PodName) != 0 { + if _, err := os.Stat(args[0]); err == nil { + return o.copyToPod(fileSpec{File: args[0]}, destSpec, &exec.ExecOptions{}) + } + return fmt.Errorf("src doesn't exist in local filesystem") + } + + if len(srcSpec.PodName) != 0 { + return o.copyFromPod(srcSpec, destSpec) + } + if len(destSpec.PodName) != 0 { + return o.copyToPod(srcSpec, destSpec, &exec.ExecOptions{}) + } + return fmt.Errorf("one of src or dest must be a remote file specification") +} + +// checkDestinationIsDir receives a destination fileSpec and +// determines if the provided destination path exists on the +// pod. If the destination path does not exist or is _not_ a +// directory, an error is returned with the exit code received. +func (o *CopyOptions) checkDestinationIsDir(dest fileSpec) error { + options := &exec.ExecOptions{ + StreamOptions: exec.StreamOptions{ + IOStreams: genericclioptions.IOStreams{ + Out: bytes.NewBuffer([]byte{}), + ErrOut: bytes.NewBuffer([]byte{}), + }, + + Namespace: dest.PodNamespace, + PodName: dest.PodName, + }, + + Command: []string{"test", "-d", dest.File}, + Executor: &exec.DefaultRemoteExecutor{}, + } + + return o.execute(options) +} + +func (o *CopyOptions) copyToPod(src, dest fileSpec, options *exec.ExecOptions) error { + if len(src.File) == 0 || len(dest.File) == 0 { + return errFileCannotBeEmpty + } + reader, writer := io.Pipe() + + // strip trailing slash (if any) + if dest.File != "/" && strings.HasSuffix(string(dest.File[len(dest.File)-1]), "/") { + dest.File = dest.File[:len(dest.File)-1] + } + + if err := o.checkDestinationIsDir(dest); err == nil { + // If no error, dest.File was found to be a directory. + // Copy specified src into it + dest.File = dest.File + "/" + path.Base(src.File) + } + + go func() { + defer writer.Close() + err := makeTar(src.File, dest.File, writer) + cmdutil.CheckErr(err) + }() + var cmdArr []string + + // TODO: Improve error messages by first testing if 'tar' is present in the container? + if o.NoPreserve { + cmdArr = []string{"tar", "--no-same-permissions", "--no-same-owner", "-xmf", "-"} + } else { + cmdArr = []string{"tar", "-xmf", "-"} + } + destDir := path.Dir(dest.File) + if len(destDir) > 0 { + cmdArr = append(cmdArr, "-C", destDir) + } + + options.StreamOptions = exec.StreamOptions{ + IOStreams: genericclioptions.IOStreams{ + In: reader, + Out: o.Out, + ErrOut: o.ErrOut, + }, + Stdin: true, + + Namespace: dest.PodNamespace, + PodName: dest.PodName, + } + + options.Command = cmdArr + options.Executor = &exec.DefaultRemoteExecutor{} + return o.execute(options) +} + +func (o *CopyOptions) copyFromPod(src, dest fileSpec) error { + if len(src.File) == 0 || len(dest.File) == 0 { + return errFileCannotBeEmpty + } + + reader, outStream := io.Pipe() + options := &exec.ExecOptions{ + StreamOptions: exec.StreamOptions{ + IOStreams: genericclioptions.IOStreams{ + In: nil, + Out: outStream, + ErrOut: o.Out, + }, + + Namespace: src.PodNamespace, + PodName: src.PodName, + }, + + // TODO: Improve error messages by first testing if 'tar' is present in the container? + Command: []string{"tar", "cf", "-", src.File}, + Executor: &exec.DefaultRemoteExecutor{}, + } + + go func() { + defer outStream.Close() + err := o.execute(options) + cmdutil.CheckErr(err) + }() + prefix := getPrefix(src.File) + prefix = path.Clean(prefix) + // remove extraneous path shortcuts - these could occur if a path contained extra "../" + // and attempted to navigate beyond "/" in a remote filesystem + prefix = stripPathShortcuts(prefix) + return o.untarAll(reader, dest.File, prefix) +} + +// stripPathShortcuts removes any leading or trailing "../" from a given path +func stripPathShortcuts(p string) string { + newPath := path.Clean(p) + trimmed := strings.TrimPrefix(newPath, "../") + + for trimmed != newPath { + newPath = trimmed + trimmed = strings.TrimPrefix(newPath, "../") + } + + // trim leftover {".", ".."} + if newPath == "." || newPath == ".." { + newPath = "" + } + + if len(newPath) > 0 && string(newPath[0]) == "/" { + return newPath[1:] + } + + return newPath +} + +func makeTar(srcPath, destPath string, writer io.Writer) error { + // TODO: use compression here? + tarWriter := tar.NewWriter(writer) + defer tarWriter.Close() + + srcPath = path.Clean(srcPath) + destPath = path.Clean(destPath) + return recursiveTar(path.Dir(srcPath), path.Base(srcPath), path.Dir(destPath), path.Base(destPath), tarWriter) +} + +func recursiveTar(srcBase, srcFile, destBase, destFile string, tw *tar.Writer) error { + srcPath := path.Join(srcBase, srcFile) + matchedPaths, err := filepath.Glob(srcPath) + if err != nil { + return err + } + for _, fpath := range matchedPaths { + stat, err := os.Lstat(fpath) + if err != nil { + return err + } + if stat.IsDir() { + files, err := ioutil.ReadDir(fpath) + if err != nil { + return err + } + if len(files) == 0 { + //case empty directory + hdr, _ := tar.FileInfoHeader(stat, fpath) + hdr.Name = destFile + if err := tw.WriteHeader(hdr); err != nil { + return err + } + } + for _, f := range files { + if err := recursiveTar(srcBase, path.Join(srcFile, f.Name()), destBase, path.Join(destFile, f.Name()), tw); err != nil { + return err + } + } + return nil + } else if stat.Mode()&os.ModeSymlink != 0 { + //case soft link + hdr, _ := tar.FileInfoHeader(stat, fpath) + target, err := os.Readlink(fpath) + if err != nil { + return err + } + + hdr.Linkname = target + hdr.Name = destFile + if err := tw.WriteHeader(hdr); err != nil { + return err + } + } else { + //case regular file or other file type like pipe + hdr, err := tar.FileInfoHeader(stat, fpath) + if err != nil { + return err + } + hdr.Name = destFile + + if err := tw.WriteHeader(hdr); err != nil { + return err + } + + f, err := os.Open(fpath) + if err != nil { + return err + } + defer f.Close() + + if _, err := io.Copy(tw, f); err != nil { + return err + } + return f.Close() + } + } + return nil +} + +func (o *CopyOptions) untarAll(reader io.Reader, destDir, prefix string) error { + // TODO: use compression here? + tarReader := tar.NewReader(reader) + for { + header, err := tarReader.Next() + if err != nil { + if err != io.EOF { + return err + } + break + } + + // All the files will start with the prefix, which is the directory where + // they were located on the pod, we need to strip down that prefix, but + // if the prefix is missing it means the tar was tempered with. + // For the case where prefix is empty we need to ensure that the path + // is not absolute, which also indicates the tar file was tempered with. + if !strings.HasPrefix(header.Name, prefix) { + return fmt.Errorf("tar contents corrupted") + } + + // basic file information + mode := header.FileInfo().Mode() + destFileName := filepath.Join(destDir, header.Name[len(prefix):]) + + if !isDestRelative(destDir, destFileName) { + fmt.Fprintf(o.IOStreams.ErrOut, "warning: file %q is outside target destination, skipping\n", destFileName) + continue + } + + baseName := filepath.Dir(destFileName) + if err := os.MkdirAll(baseName, 0755); err != nil { + return err + } + if header.FileInfo().IsDir() { + if err := os.MkdirAll(destFileName, 0755); err != nil { + return err + } + continue + } + + // We need to ensure that the destination file is always within boundries + // of the destination directory. This prevents any kind of path traversal + // from within tar archive. + evaledPath, err := filepath.EvalSymlinks(baseName) + if err != nil { + return err + } + // For scrutiny we verify both the actual destination as well as we follow + // all the links that might lead outside of the destination directory. + if !isDestRelative(destDir, filepath.Join(evaledPath, filepath.Base(destFileName))) { + fmt.Fprintf(o.IOStreams.ErrOut, "warning: file %q is outside target destination, skipping\n", destFileName) + continue + } + + if mode&os.ModeSymlink != 0 { + linkname := header.Linkname + // We need to ensure that the link destination is always within boundries + // of the destination directory. This prevents any kind of path traversal + // from within tar archive. + linkTarget := linkname + if !filepath.IsAbs(linkname) { + linkTarget = filepath.Join(evaledPath, linkname) + } + if !isDestRelative(destDir, linkTarget) { + fmt.Fprintf(o.IOStreams.ErrOut, "warning: link %q is pointing to %q which is outside target destination, skipping\n", destFileName, header.Linkname) + continue + } + if err := os.Symlink(linkname, destFileName); err != nil { + return err + } + } else { + outFile, err := os.Create(destFileName) + if err != nil { + return err + } + defer outFile.Close() + if _, err := io.Copy(outFile, tarReader); err != nil { + return err + } + if err := outFile.Close(); err != nil { + return err + } + } + } + + return nil +} + +// isDestRelative returns true if dest is pointing outside the base directory, +// false otherwise. +func isDestRelative(base, dest string) bool { + relative, err := filepath.Rel(base, dest) + if err != nil { + return false + } + return relative == "." || relative == stripPathShortcuts(relative) +} + +func getPrefix(file string) string { + // tar strips the leading '/' if it's there, so we will too + return strings.TrimLeft(file, "/") +} + +func (o *CopyOptions) execute(options *exec.ExecOptions) error { + if len(options.Namespace) == 0 { + options.Namespace = o.Namespace + } + + if len(o.Container) > 0 { + options.ContainerName = o.Container + } + + options.Config = o.ClientConfig + options.PodClient = o.Clientset.CoreV1() + + if err := options.Validate(); err != nil { + return err + } + + if err := options.Run(); err != nil { + return err + } + return nil +} diff --git a/pkg/cmd/cp/cp_test.go b/pkg/cmd/cp/cp_test.go new file mode 100644 index 000000000..fdd86165d --- /dev/null +++ b/pkg/cmd/cp/cp_test.go @@ -0,0 +1,975 @@ +/* +Copyright 2014 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 cp + +import ( + "archive/tar" + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + kexec "k8s.io/kubernetes/pkg/kubectl/cmd/exec" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +type FileType int + +const ( + RegularFile FileType = 0 + SymLink FileType = 1 + RegexFile FileType = 2 +) + +func TestExtractFileSpec(t *testing.T) { + tests := []struct { + spec string + expectedPod string + expectedNamespace string + expectedFile string + expectErr bool + }{ + { + spec: "namespace/pod:/some/file", + expectedPod: "pod", + expectedNamespace: "namespace", + expectedFile: "/some/file", + }, + { + spec: "pod:/some/file", + expectedPod: "pod", + expectedFile: "/some/file", + }, + { + spec: "/some/file", + expectedFile: "/some/file", + }, + { + spec: ":file:not:exist:in:local:filesystem", + expectErr: true, + }, + { + spec: "namespace/pod/invalid:/some/file", + expectErr: true, + }, + { + spec: "pod:/some/filenamewith:in", + expectedPod: "pod", + expectedFile: "/some/filenamewith:in", + }, + } + for _, test := range tests { + spec, err := extractFileSpec(test.spec) + if test.expectErr && err == nil { + t.Errorf("unexpected non-error") + continue + } + if err != nil && !test.expectErr { + t.Errorf("unexpected error: %v", err) + continue + } + if spec.PodName != test.expectedPod { + t.Errorf("expected: %s, saw: %s", test.expectedPod, spec.PodName) + } + if spec.PodNamespace != test.expectedNamespace { + t.Errorf("expected: %s, saw: %s", test.expectedNamespace, spec.PodNamespace) + } + if spec.File != test.expectedFile { + t.Errorf("expected: %s, saw: %s", test.expectedFile, spec.File) + } + } +} + +func TestGetPrefix(t *testing.T) { + tests := []struct { + input string + expected string + }{ + { + input: "/foo/bar", + expected: "foo/bar", + }, + { + input: "foo/bar", + expected: "foo/bar", + }, + } + for _, test := range tests { + out := getPrefix(test.input) + if out != test.expected { + t.Errorf("expected: %s, saw: %s", test.expected, out) + } + } +} + +func TestStripPathShortcuts(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "test single path shortcut prefix", + input: "../foo/bar", + expected: "foo/bar", + }, + { + name: "test multiple path shortcuts", + input: "../../foo/bar", + expected: "foo/bar", + }, + { + name: "test multiple path shortcuts with absolute path", + input: "/tmp/one/two/../../foo/bar", + expected: "tmp/foo/bar", + }, + { + name: "test multiple path shortcuts with no named directory", + input: "../../", + expected: "", + }, + { + name: "test multiple path shortcuts with no named directory and no trailing slash", + input: "../..", + expected: "", + }, + { + name: "test multiple path shortcuts with absolute path and filename containing leading dots", + input: "/tmp/one/two/../../foo/..bar", + expected: "tmp/foo/..bar", + }, + { + name: "test multiple path shortcuts with no named directory and filename containing leading dots", + input: "../...foo", + expected: "...foo", + }, + { + name: "test filename containing leading dots", + input: "...foo", + expected: "...foo", + }, + { + name: "test root directory", + input: "/", + expected: "", + }, + } + + for _, test := range tests { + out := stripPathShortcuts(test.input) + if out != test.expected { + t.Errorf("expected: %s, saw: %s", test.expected, out) + } + } +} +func TestIsDestRelative(t *testing.T) { + tests := []struct { + base string + dest string + relative bool + }{ + { + base: "/dir", + dest: "/dir/../link", + relative: false, + }, + { + base: "/dir", + dest: "/dir/../../link", + relative: false, + }, + { + base: "/dir", + dest: "/link", + relative: false, + }, + { + base: "/dir", + dest: "/dir/link", + relative: true, + }, + { + base: "/dir", + dest: "/dir/int/../link", + relative: true, + }, + { + base: "dir", + dest: "dir/link", + relative: true, + }, + { + base: "dir", + dest: "dir/int/../link", + relative: true, + }, + { + base: "dir", + dest: "dir/../../link", + relative: false, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + if test.relative != isDestRelative(test.base, test.dest) { + t.Errorf("unexpected result for: base %q, dest %q", test.base, test.dest) + } + }) + } +} + +func checkErr(t *testing.T, err error) { + if err != nil { + t.Errorf("unexpected error: %v", err) + t.FailNow() + } +} + +func TestTarUntar(t *testing.T) { + dir, err := ioutil.TempDir("", "input") + checkErr(t, err) + dir2, err := ioutil.TempDir("", "output") + checkErr(t, err) + dir3, err := ioutil.TempDir("", "dir") + checkErr(t, err) + + dir = dir + "/" + defer func() { + os.RemoveAll(dir) + os.RemoveAll(dir2) + os.RemoveAll(dir3) + }() + + files := []struct { + name string + nameList []string + data string + omitted bool + fileType FileType + }{ + { + name: "foo", + data: "foobarbaz", + fileType: RegularFile, + }, + { + name: "dir/blah", + data: "bazblahfoo", + fileType: RegularFile, + }, + { + name: "some/other/directory/", + data: "with more data here", + fileType: RegularFile, + }, + { + name: "blah", + data: "same file name different data", + fileType: RegularFile, + }, + { + name: "gakki", + data: "tmp/gakki", + fileType: SymLink, + }, + { + name: "relative_to_dest", + data: path.Join(dir2, "foo"), + fileType: SymLink, + }, + { + name: "tricky_relative", + data: path.Join(dir3, "xyz"), + omitted: true, + fileType: SymLink, + }, + { + name: "absolute_path", + data: "/tmp/gakki", + omitted: true, + fileType: SymLink, + }, + { + name: "blah*", + nameList: []string{"blah1", "blah2"}, + data: "regexp file name", + fileType: RegexFile, + }, + } + + for _, file := range files { + filepath := path.Join(dir, file.name) + if err := os.MkdirAll(path.Dir(filepath), 0755); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if file.fileType == RegularFile { + createTmpFile(t, filepath, file.data) + } else if file.fileType == SymLink { + err := os.Symlink(file.data, filepath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else if file.fileType == RegexFile { + for _, fileName := range file.nameList { + createTmpFile(t, path.Join(dir, fileName), file.data) + } + } else { + t.Fatalf("unexpected file type: %v", file) + } + } + + opts := NewCopyOptions(genericclioptions.NewTestIOStreamsDiscard()) + + writer := &bytes.Buffer{} + if err := makeTar(dir, dir, writer); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + reader := bytes.NewBuffer(writer.Bytes()) + if err := opts.untarAll(reader, dir2, ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, file := range files { + absPath := filepath.Join(dir2, strings.TrimPrefix(dir, os.TempDir())) + filePath := filepath.Join(absPath, file.name) + + if file.fileType == RegularFile { + cmpFileData(t, filePath, file.data) + } else if file.fileType == SymLink { + dest, err := os.Readlink(filePath) + if file.omitted { + if err != nil && strings.Contains(err.Error(), "no such file or directory") { + continue + } + t.Fatalf("expected to omit symlink for %s", filePath) + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if file.data != dest { + t.Fatalf("expected: %s, saw: %s", file.data, dest) + } + } else if file.fileType == RegexFile { + for _, fileName := range file.nameList { + cmpFileData(t, path.Join(dir, fileName), file.data) + } + } else { + t.Fatalf("unexpected file type: %v", file) + } + } +} + +func TestTarUntarWrongPrefix(t *testing.T) { + dir, err := ioutil.TempDir("", "input") + checkErr(t, err) + dir2, err := ioutil.TempDir("", "output") + checkErr(t, err) + + dir = dir + "/" + defer func() { + os.RemoveAll(dir) + os.RemoveAll(dir2) + }() + + filepath := path.Join(dir, "foo") + if err := os.MkdirAll(path.Dir(filepath), 0755); err != nil { + t.Fatalf("unexpected error: %v", err) + } + createTmpFile(t, filepath, "sample data") + + opts := NewCopyOptions(genericclioptions.NewTestIOStreamsDiscard()) + + writer := &bytes.Buffer{} + if err := makeTar(dir, dir, writer); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + reader := bytes.NewBuffer(writer.Bytes()) + err = opts.untarAll(reader, dir2, "verylongprefix-showing-the-tar-was-tempered-with") + if err == nil || !strings.Contains(err.Error(), "tar contents corrupted") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTarDestinationName(t *testing.T) { + dir, err := ioutil.TempDir(os.TempDir(), "input") + dir2, err2 := ioutil.TempDir(os.TempDir(), "output") + if err != nil || err2 != nil { + t.Errorf("unexpected error: %v | %v", err, err2) + t.FailNow() + } + defer func() { + if err := os.RemoveAll(dir); err != nil { + t.Errorf("Unexpected error cleaning up: %v", err) + } + if err := os.RemoveAll(dir2); err != nil { + t.Errorf("Unexpected error cleaning up: %v", err) + } + }() + + files := []struct { + name string + data string + }{ + { + name: "foo", + data: "foobarbaz", + }, + { + name: "dir/blah", + data: "bazblahfoo", + }, + { + name: "some/other/directory", + data: "with more data here", + }, + { + name: "blah", + data: "same file name different data", + }, + } + + // ensure files exist on disk + for _, file := range files { + filepath := path.Join(dir, file.name) + if err := os.MkdirAll(path.Dir(filepath), 0755); err != nil { + t.Errorf("unexpected error: %v", err) + t.FailNow() + } + createTmpFile(t, filepath, file.data) + } + + reader, writer := io.Pipe() + go func() { + if err := makeTar(dir, dir2, writer); err != nil { + t.Errorf("unexpected error: %v", err) + } + }() + + tarReader := tar.NewReader(reader) + for { + hdr, err := tarReader.Next() + if err == io.EOF { + break + } else if err != nil { + t.Errorf("unexpected error: %v", err) + t.FailNow() + } + + if !strings.HasPrefix(hdr.Name, path.Base(dir2)) { + t.Errorf("expected %q as destination filename prefix, saw: %q", path.Base(dir2), hdr.Name) + } + } +} + +func TestBadTar(t *testing.T) { + dir, err := ioutil.TempDir(os.TempDir(), "dest") + if err != nil { + t.Errorf("unexpected error: %v ", err) + t.FailNow() + } + defer os.RemoveAll(dir) + + // More or less cribbed from https://golang.org/pkg/archive/tar/#example__minimal + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + var files = []struct { + name string + body string + }{ + {"/prefix/foo/bar/../../home/bburns/names.txt", "Down and back"}, + } + for _, file := range files { + hdr := &tar.Header{ + Name: file.name, + Mode: 0600, + Size: int64(len(file.body)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Errorf("unexpected error: %v ", err) + t.FailNow() + } + if _, err := tw.Write([]byte(file.body)); err != nil { + t.Errorf("unexpected error: %v ", err) + t.FailNow() + } + } + if err := tw.Close(); err != nil { + t.Errorf("unexpected error: %v ", err) + t.FailNow() + } + + opts := NewCopyOptions(genericclioptions.NewTestIOStreamsDiscard()) + if err := opts.untarAll(&buf, dir, "/prefix"); err != nil { + t.Errorf("unexpected error: %v ", err) + t.FailNow() + } + + for _, file := range files { + _, err := os.Stat(path.Join(dir, path.Clean(file.name[len("/prefix"):]))) + if err != nil { + t.Errorf("Error finding file: %v", err) + } + } +} + +// clean prevents path traversals by stripping them out. +// This is adapted from https://golang.org/src/net/http/fs.go#L74 +func clean(fileName string) string { + return path.Clean(string(os.PathSeparator) + fileName) +} + +func TestClean(t *testing.T) { + tests := []struct { + input string + cleaned string + }{ + { + "../../../tmp/foo", + "/tmp/foo", + }, + { + "/../../../tmp/foo", + "/tmp/foo", + }, + } + + for _, test := range tests { + out := clean(test.input) + if out != test.cleaned { + t.Errorf("Expected: %s, saw %s", test.cleaned, out) + } + } +} + +func TestCopyToPod(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + ns := scheme.Codecs + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + responsePod := &v1.Pod{} + return &http.Response{StatusCode: http.StatusNotFound, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(codec, responsePod))))}, nil + }), + } + + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdCp(tf, ioStreams) + + srcFile, err := ioutil.TempDir("", "test") + if err != nil { + t.Errorf("unexpected error: %v", err) + t.FailNow() + } + defer os.RemoveAll(srcFile) + + tests := map[string]struct { + dest string + expectedErr bool + }{ + "copy to directory": { + dest: "/tmp/", + expectedErr: false, + }, + "copy to root": { + dest: "/", + expectedErr: false, + }, + "copy to empty file name": { + dest: "", + expectedErr: true, + }, + } + + for name, test := range tests { + opts := NewCopyOptions(ioStreams) + src := fileSpec{ + File: srcFile, + } + dest := fileSpec{ + PodNamespace: "pod-ns", + PodName: "pod-name", + File: test.dest, + } + opts.Complete(tf, cmd) + t.Run(name, func(t *testing.T) { + err = opts.copyToPod(src, dest, &kexec.ExecOptions{}) + //If error is NotFound error , it indicates that the + //request has been sent correctly. + //Treat this as no error. + if test.expectedErr && errors.IsNotFound(err) { + t.Errorf("expected error but didn't get one") + } + if !test.expectedErr && !errors.IsNotFound(err) { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestCopyToPodNoPreserve(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + ns := scheme.Codecs + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + responsePod := &v1.Pod{} + return &http.Response{StatusCode: http.StatusNotFound, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(codec, responsePod))))}, nil + }), + } + + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdCp(tf, ioStreams) + + srcFile, err := ioutil.TempDir("", "test") + if err != nil { + t.Errorf("unexpected error: %v", err) + t.FailNow() + } + defer os.RemoveAll(srcFile) + + tests := map[string]struct { + expectedCmd []string + nopreserve bool + }{ + "copy to pod no preserve user and permissions": { + expectedCmd: []string{"tar", "--no-same-permissions", "--no-same-owner", "-xmf", "-", "-C", "."}, + nopreserve: true, + }, + "copy to pod preserve user and permissions": { + expectedCmd: []string{"tar", "-xmf", "-", "-C", "."}, + nopreserve: false, + }, + } + opts := NewCopyOptions(ioStreams) + src := fileSpec{ + File: srcFile, + } + dest := fileSpec{ + PodNamespace: "pod-ns", + PodName: "pod-name", + File: "foo", + } + opts.Complete(tf, cmd) + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + options := &kexec.ExecOptions{} + opts.NoPreserve = test.nopreserve + err = opts.copyToPod(src, dest, options) + if !(reflect.DeepEqual(test.expectedCmd, options.Command)) { + t.Errorf("expected cmd: %v, got: %v", test.expectedCmd, options.Command) + } + }) + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + args []string + expectedErr bool + }{ + { + name: "Validate Succeed", + args: []string{"1", "2"}, + expectedErr: false, + }, + { + name: "Validate Fail", + args: []string{"1", "2", "3"}, + expectedErr: true, + }, + } + tf := cmdtesting.NewTestFactory() + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + opts := NewCopyOptions(ioStreams) + cmd := NewCmdCp(tf, ioStreams) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := opts.Validate(cmd, test.args) + if (err != nil) != test.expectedErr { + t.Errorf("expected error: %v, saw: %v, error: %v", test.expectedErr, err != nil, err) + } + }) + } +} + +func TestUntar(t *testing.T) { + testdir, err := ioutil.TempDir("", "test-untar") + require.NoError(t, err) + defer os.RemoveAll(testdir) + t.Logf("Test base: %s", testdir) + + basedir := filepath.Join(testdir, "base") + + type file struct { + path string + linkTarget string // For link types + expected string // Expect to find the file here (or not, if empty) + } + files := []file{{ + // Absolute file within dest + path: filepath.Join(basedir, "abs"), + expected: filepath.Join(basedir, basedir, "abs"), + }, { // Absolute file outside dest + path: filepath.Join(testdir, "abs-out"), + expected: filepath.Join(basedir, testdir, "abs-out"), + }, { // Absolute nested file within dest + path: filepath.Join(basedir, "nested/nest-abs"), + expected: filepath.Join(basedir, basedir, "nested/nest-abs"), + }, { // Absolute nested file outside dest + path: filepath.Join(basedir, "nested/../../nest-abs-out"), + expected: filepath.Join(basedir, testdir, "nest-abs-out"), + }, { // Relative file inside dest + path: "relative", + expected: filepath.Join(basedir, "relative"), + }, { // Relative file outside dest + path: "../unrelative", + expected: "", + }, { // Nested relative file inside dest + path: "nested/nest-rel", + expected: filepath.Join(basedir, "nested/nest-rel"), + }, { // Nested relative file outside dest + path: "nested/../../nest-unrelative", + expected: "", + }} + + mkExpectation := func(expected, suffix string) string { + if expected == "" { + return "" + } + return expected + suffix + } + mkBacklinkExpectation := func(expected, suffix string) string { + // "resolve" the back link relative to the expectation + targetDir := filepath.Dir(filepath.Dir(expected)) + // If the "resolved" target is not nested in basedir, it is escaping. + if !filepath.HasPrefix(targetDir, basedir) { + return "" + } + return expected + suffix + } + links := []file{} + for _, f := range files { + links = append(links, file{ + path: f.path + "-innerlink", + linkTarget: "link-target", + expected: mkExpectation(f.expected, "-innerlink"), + }, file{ + path: f.path + "-innerlink-abs", + linkTarget: filepath.Join(basedir, "link-target"), + expected: mkExpectation(f.expected, "-innerlink-abs"), + }, file{ + path: f.path + "-backlink", + linkTarget: filepath.Join("..", "link-target"), + expected: mkBacklinkExpectation(f.expected, "-backlink"), + }, file{ + path: f.path + "-outerlink-abs", + linkTarget: filepath.Join(testdir, "link-target"), + expected: "", + }) + + if f.expected != "" { + // outerlink is the number of backticks to escape to testdir + outerlink, _ := filepath.Rel(f.expected, testdir) + links = append(links, file{ + path: f.path + "outerlink", + linkTarget: filepath.Join(outerlink, "link-target"), + expected: "", + }) + } + } + files = append(files, links...) + + // Test back-tick escaping through a symlink. + files = append(files, + file{ + path: "nested/again/back-link", + linkTarget: "../../nested", + expected: filepath.Join(basedir, "nested/again/back-link"), + }, + file{ + path: "nested/again/back-link/../../../back-link-file", + expected: filepath.Join(basedir, "back-link-file"), + }) + + // Test chaining back-tick symlinks. + files = append(files, + file{ + path: "nested/back-link-first", + linkTarget: "../", + expected: filepath.Join(basedir, "nested/back-link-first"), + }, + file{ + path: "nested/back-link-first/back-link-second", + linkTarget: "../", + expected: "", + }) + + files = append(files, + file{ // Relative directory path with terminating / + path: "direct/dir/", + expected: "", + }) + + buf := &bytes.Buffer{} + tw := tar.NewWriter(buf) + expectations := map[string]bool{} + for _, f := range files { + if f.expected != "" { + expectations[f.expected] = false + } + if f.linkTarget == "" { + hdr := &tar.Header{ + Name: f.path, + Mode: 0666, + Size: int64(len(f.path)), + } + require.NoError(t, tw.WriteHeader(hdr), f.path) + if !strings.HasSuffix(f.path, "/") { + _, err := tw.Write([]byte(f.path)) + require.NoError(t, err, f.path) + } + } else { + hdr := &tar.Header{ + Name: f.path, + Mode: int64(0777 | os.ModeSymlink), + Typeflag: tar.TypeSymlink, + Linkname: f.linkTarget, + } + require.NoError(t, tw.WriteHeader(hdr), f.path) + } + } + tw.Close() + + // Capture warnings to stderr for debugging. + output := (*testWriter)(t) + opts := NewCopyOptions(genericclioptions.IOStreams{In: &bytes.Buffer{}, Out: output, ErrOut: output}) + + require.NoError(t, opts.untarAll(buf, filepath.Join(basedir), "")) + + filepath.Walk(testdir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil // Ignore directories. + } + if _, ok := expectations[path]; !ok { + t.Errorf("Unexpected file at %s", path) + } else { + expectations[path] = true + } + return nil + }) + for path, found := range expectations { + if !found { + t.Errorf("Missing expected file %s", path) + } + } +} + +func TestUntar_SingleFile(t *testing.T) { + testdir, err := ioutil.TempDir("", "test-untar") + require.NoError(t, err) + defer os.RemoveAll(testdir) + + dest := filepath.Join(testdir, "target") + + buf := &bytes.Buffer{} + tw := tar.NewWriter(buf) + + const ( + srcName = "source" + content = "file contents" + ) + hdr := &tar.Header{ + Name: srcName, + Mode: 0666, + Size: int64(len(content)), + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err = tw.Write([]byte(content)) + require.NoError(t, err) + tw.Close() + + // Capture warnings to stderr for debugging. + output := (*testWriter)(t) + opts := NewCopyOptions(genericclioptions.IOStreams{In: &bytes.Buffer{}, Out: output, ErrOut: output}) + + require.NoError(t, opts.untarAll(buf, filepath.Join(dest), srcName)) + cmpFileData(t, dest, content) +} + +func createTmpFile(t *testing.T, filepath, data string) { + f, err := os.Create(filepath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer f.Close() + if _, err := io.Copy(f, bytes.NewBuffer([]byte(data))); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } +} + +func cmpFileData(t *testing.T, filePath, data string) { + actual, err := ioutil.ReadFile(filePath) + require.NoError(t, err) + assert.EqualValues(t, data, actual) +} + +type testWriter testing.T + +func (t *testWriter) Write(p []byte) (n int, err error) { + t.Logf(string(p)) + return len(p), nil +} diff --git a/pkg/cmd/create/create.go b/pkg/cmd/create/create.go new file mode 100644 index 000000000..19c88c9fb --- /dev/null +++ b/pkg/cmd/create/create.go @@ -0,0 +1,440 @@ +/* +Copyright 2014 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 create + +import ( + "fmt" + "io" + "net/url" + "runtime" + "strings" + + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + kruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/dynamic" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/cmd/util/editor" + "k8s.io/kubectl/pkg/generate" + "k8s.io/kubectl/pkg/rawhttp" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// CreateOptions is the commandline options for 'create' sub command +type CreateOptions struct { + PrintFlags *genericclioptions.PrintFlags + RecordFlags *genericclioptions.RecordFlags + + DryRun bool + + FilenameOptions resource.FilenameOptions + Selector string + EditBeforeCreate bool + Raw string + + Recorder genericclioptions.Recorder + PrintObj func(obj kruntime.Object) error + + genericclioptions.IOStreams +} + +var ( + createLong = templates.LongDesc(i18n.T(` + Create a resource from a file or from stdin. + + JSON and YAML formats are accepted.`)) + + createExample = templates.Examples(i18n.T(` + # Create a pod using the data in pod.json. + kubectl create -f ./pod.json + + # Create a pod based on the JSON passed into stdin. + cat pod.json | kubectl create -f - + + # Edit the data in docker-registry.yaml in JSON then create the resource using the edited data. + kubectl create -f docker-registry.yaml --edit -o json`)) +) + +// NewCreateOptions returns an initialized CreateOptions instance +func NewCreateOptions(ioStreams genericclioptions.IOStreams) *CreateOptions { + return &CreateOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + RecordFlags: genericclioptions.NewRecordFlags(), + + Recorder: genericclioptions.NoopRecorder{}, + + IOStreams: ioStreams, + } +} + +// NewCmdCreate returns new initialized instance of create sub command +func NewCmdCreate(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewCreateOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "create -f FILENAME", + DisableFlagsInUseLine: true, + Short: i18n.T("Create a resource from a file or from stdin."), + Long: createLong, + Example: createExample, + Run: func(cmd *cobra.Command, args []string) { + if cmdutil.IsFilenameSliceEmpty(o.FilenameOptions.Filenames, o.FilenameOptions.Kustomize) { + ioStreams.ErrOut.Write([]byte("Error: must specify one of -f and -k\n\n")) + defaultRunFunc := cmdutil.DefaultSubCommandRun(ioStreams.ErrOut) + defaultRunFunc(cmd, args) + return + } + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(o.ValidateArgs(cmd, args)) + cmdutil.CheckErr(o.RunCreate(f, cmd)) + }, + } + + // bind flag structs + o.RecordFlags.AddFlags(cmd) + + usage := "to use to create the resource" + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + cmdutil.AddValidateFlags(cmd) + cmd.Flags().BoolVar(&o.EditBeforeCreate, "edit", o.EditBeforeCreate, "Edit the API resource before creating") + cmd.Flags().Bool("windows-line-endings", runtime.GOOS == "windows", + "Only relevant if --edit=true. Defaults to the line ending native to your platform.") + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddDryRunFlag(cmd) + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().StringVar(&o.Raw, "raw", o.Raw, "Raw URI to POST to the server. Uses the transport specified by the kubeconfig file.") + + o.PrintFlags.AddFlags(cmd) + + // create subcommands + cmd.AddCommand(NewCmdCreateNamespace(f, ioStreams)) + cmd.AddCommand(NewCmdCreateQuota(f, ioStreams)) + cmd.AddCommand(NewCmdCreateSecret(f, ioStreams)) + cmd.AddCommand(NewCmdCreateConfigMap(f, ioStreams)) + cmd.AddCommand(NewCmdCreateServiceAccount(f, ioStreams)) + cmd.AddCommand(NewCmdCreateService(f, ioStreams)) + cmd.AddCommand(NewCmdCreateDeployment(f, ioStreams)) + cmd.AddCommand(NewCmdCreateClusterRole(f, ioStreams)) + cmd.AddCommand(NewCmdCreateClusterRoleBinding(f, ioStreams)) + cmd.AddCommand(NewCmdCreateRole(f, ioStreams)) + cmd.AddCommand(NewCmdCreateRoleBinding(f, ioStreams)) + cmd.AddCommand(NewCmdCreatePodDisruptionBudget(f, ioStreams)) + cmd.AddCommand(NewCmdCreatePriorityClass(f, ioStreams)) + cmd.AddCommand(NewCmdCreateJob(f, ioStreams)) + cmd.AddCommand(NewCmdCreateCronJob(f, ioStreams)) + return cmd +} + +// ValidateArgs makes sure there is no discrepency in command options +func (o *CreateOptions) ValidateArgs(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args) + } + if len(o.Raw) > 0 { + if o.EditBeforeCreate { + return cmdutil.UsageErrorf(cmd, "--raw and --edit are mutually exclusive") + } + if len(o.FilenameOptions.Filenames) != 1 { + return cmdutil.UsageErrorf(cmd, "--raw can only use a single local file or stdin") + } + if strings.Index(o.FilenameOptions.Filenames[0], "http://") == 0 || strings.Index(o.FilenameOptions.Filenames[0], "https://") == 0 { + return cmdutil.UsageErrorf(cmd, "--raw cannot read from a url") + } + if o.FilenameOptions.Recursive { + return cmdutil.UsageErrorf(cmd, "--raw and --recursive are mutually exclusive") + } + if len(o.Selector) > 0 { + return cmdutil.UsageErrorf(cmd, "--raw and --selector (-l) are mutually exclusive") + } + if len(cmdutil.GetFlagString(cmd, "output")) > 0 { + return cmdutil.UsageErrorf(cmd, "--raw and --output are mutually exclusive") + } + if _, err := url.ParseRequestURI(o.Raw); err != nil { + return cmdutil.UsageErrorf(cmd, "--raw must be a valid URL path: %v", err) + } + } + + return nil +} + +// Complete completes all the required options +func (o *CreateOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + var err error + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.DryRun = cmdutil.GetDryRunFlag(cmd) + + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + + o.PrintObj = func(obj kruntime.Object) error { + return printer.PrintObj(obj, o.Out) + } + + return nil +} + +// RunCreate performs the creation +func (o *CreateOptions) RunCreate(f cmdutil.Factory, cmd *cobra.Command) error { + // raw only makes sense for a single file resource multiple objects aren't likely to do what you want. + // the validator enforces this, so + if len(o.Raw) > 0 { + restClient, err := f.RESTClient() + if err != nil { + return err + } + return rawhttp.RawPost(restClient, o.IOStreams, o.Raw, o.FilenameOptions.Filenames[0]) + } + + if o.EditBeforeCreate { + return RunEditOnCreate(f, o.PrintFlags, o.RecordFlags, o.IOStreams, cmd, &o.FilenameOptions) + } + schema, err := f.Validator(cmdutil.GetFlagBool(cmd, "validate")) + if err != nil { + return err + } + + cmdNamespace, enforceNamespace, err := f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + r := f.NewBuilder(). + Unstructured(). + Schema(schema). + ContinueOnError(). + NamespaceParam(cmdNamespace).DefaultNamespace(). + FilenameParam(enforceNamespace, &o.FilenameOptions). + LabelSelectorParam(o.Selector). + Flatten(). + Do() + err = r.Err() + if err != nil { + return err + } + + count := 0 + err = r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + if err := util.CreateOrUpdateAnnotation(cmdutil.GetFlagBool(cmd, cmdutil.ApplyAnnotationsFlag), info.Object, scheme.DefaultJSONEncoder()); err != nil { + return cmdutil.AddSourceToErr("creating", info.Source, err) + } + + if err := o.Recorder.Record(info.Object); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + if !o.DryRun { + if err := createAndRefresh(info); err != nil { + return cmdutil.AddSourceToErr("creating", info.Source, err) + } + } + + count++ + + return o.PrintObj(info.Object) + }) + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("no objects passed to create") + } + return nil +} + +// RunEditOnCreate performs edit on creation +func RunEditOnCreate(f cmdutil.Factory, printFlags *genericclioptions.PrintFlags, recordFlags *genericclioptions.RecordFlags, ioStreams genericclioptions.IOStreams, cmd *cobra.Command, options *resource.FilenameOptions) error { + editOptions := editor.NewEditOptions(editor.EditBeforeCreateMode, ioStreams) + editOptions.FilenameOptions = *options + editOptions.ValidateOptions = cmdutil.ValidateOptions{ + EnableValidation: cmdutil.GetFlagBool(cmd, "validate"), + } + editOptions.PrintFlags = printFlags + editOptions.ApplyAnnotation = cmdutil.GetFlagBool(cmd, cmdutil.ApplyAnnotationsFlag) + editOptions.RecordFlags = recordFlags + + err := editOptions.Complete(f, []string{}, cmd) + if err != nil { + return err + } + return editOptions.Run() +} + +// createAndRefresh creates an object from input info and refreshes info with that object +func createAndRefresh(info *resource.Info) error { + obj, err := resource.NewHelper(info.Client, info.Mapping).Create(info.Namespace, true, info.Object, nil) + if err != nil { + return err + } + info.Refresh(obj, true) + return nil +} + +// NameFromCommandArgs is a utility function for commands that assume the first argument is a resource name +func NameFromCommandArgs(cmd *cobra.Command, args []string) (string, error) { + argsLen := cmd.ArgsLenAtDash() + // ArgsLenAtDash returns -1 when -- was not specified + if argsLen == -1 { + argsLen = len(args) + } + if argsLen != 1 { + return "", cmdutil.UsageErrorf(cmd, "exactly one NAME is required, got %d", argsLen) + } + return args[0], nil +} + +// CreateSubcommandOptions is an options struct to support create subcommands +type CreateSubcommandOptions struct { + // PrintFlags holds options necessary for obtaining a printer + PrintFlags *genericclioptions.PrintFlags + // Name of resource being created + Name string + // StructuredGenerator is the resource generator for the object being created + StructuredGenerator generate.StructuredGenerator + // DryRun is true if the command should be simulated but not run against the server + DryRun bool + CreateAnnotation bool + + Namespace string + EnforceNamespace bool + + Mapper meta.RESTMapper + DynamicClient dynamic.Interface + + PrintObj printers.ResourcePrinterFunc + + genericclioptions.IOStreams +} + +// NewCreateSubcommandOptions returns initialized CreateSubcommandOptions +func NewCreateSubcommandOptions(ioStreams genericclioptions.IOStreams) *CreateSubcommandOptions { + return &CreateSubcommandOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + IOStreams: ioStreams, + } +} + +// Complete completes all the required options +func (o *CreateSubcommandOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string, generator generate.StructuredGenerator) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + o.Name = name + o.StructuredGenerator = generator + o.DryRun = cmdutil.GetDryRunFlag(cmd) + o.CreateAnnotation = cmdutil.GetFlagBool(cmd, cmdutil.ApplyAnnotationsFlag) + + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + + o.PrintObj = func(obj kruntime.Object, out io.Writer) error { + return printer.PrintObj(obj, out) + } + + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.DynamicClient, err = f.DynamicClient() + if err != nil { + return err + } + + o.Mapper, err = f.ToRESTMapper() + if err != nil { + return err + } + + return nil +} + +// Run executes a create subcommand using the specified options +func (o *CreateSubcommandOptions) Run() error { + obj, err := o.StructuredGenerator.StructuredGenerate() + if err != nil { + return err + } + if !o.DryRun { + // create subcommands have compiled knowledge of things they create, so type them directly + gvks, _, err := scheme.Scheme.ObjectKinds(obj) + if err != nil { + return err + } + gvk := gvks[0] + mapping, err := o.Mapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) + if err != nil { + return err + } + + if err := util.CreateOrUpdateAnnotation(o.CreateAnnotation, obj, scheme.DefaultJSONEncoder()); err != nil { + return err + } + + asUnstructured := &unstructured.Unstructured{} + + if err := scheme.Scheme.Convert(obj, asUnstructured, nil); err != nil { + return err + } + if mapping.Scope.Name() == meta.RESTScopeNameRoot { + o.Namespace = "" + } + actualObject, err := o.DynamicClient.Resource(mapping.Resource).Namespace(o.Namespace).Create(asUnstructured, metav1.CreateOptions{}) + if err != nil { + return err + } + + // ensure we pass a versioned object to the printer + obj = actualObject + } else { + if meta, err := meta.Accessor(obj); err == nil && o.EnforceNamespace { + meta.SetNamespace(o.Namespace) + } + } + + return o.PrintObj(obj, o.Out) +} diff --git a/pkg/cmd/create/create_clusterrole.go b/pkg/cmd/create/create_clusterrole.go new file mode 100644 index 000000000..54369988f --- /dev/null +++ b/pkg/cmd/create/create_clusterrole.go @@ -0,0 +1,210 @@ +/* +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 create + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + cliflag "k8s.io/component-base/cli/flag" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + clusterRoleLong = templates.LongDesc(i18n.T(` + Create a ClusterRole.`)) + + clusterRoleExample = templates.Examples(i18n.T(` + # Create a ClusterRole named "pod-reader" that allows user to perform "get", "watch" and "list" on pods + kubectl create clusterrole pod-reader --verb=get,list,watch --resource=pods + + # Create a ClusterRole named "pod-reader" with ResourceName specified + kubectl create clusterrole pod-reader --verb=get --resource=pods --resource-name=readablepod --resource-name=anotherpod + + # Create a ClusterRole named "foo" with API Group specified + kubectl create clusterrole foo --verb=get,list,watch --resource=rs.extensions + + # Create a ClusterRole named "foo" with SubResource specified + kubectl create clusterrole foo --verb=get,list,watch --resource=pods,pods/status + + # Create a ClusterRole name "foo" with NonResourceURL specified + kubectl create clusterrole "foo" --verb=get --non-resource-url=/logs/* + + # Create a ClusterRole name "monitoring" with AggregationRule specified + kubectl create clusterrole monitoring --aggregation-rule="rbac.example.com/aggregate-to-monitoring=true"`)) + + // Valid nonResource verb list for validation. + validNonResourceVerbs = []string{"*", "get", "post", "put", "delete", "patch", "head", "options"} +) + +// CreateClusterRoleOptions is returned by NewCmdCreateClusterRole +type CreateClusterRoleOptions struct { + *CreateRoleOptions + NonResourceURLs []string + AggregationRule map[string]string +} + +// NewCmdCreateClusterRole initializes and returns new ClusterRoles command +func NewCmdCreateClusterRole(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + c := &CreateClusterRoleOptions{ + CreateRoleOptions: NewCreateRoleOptions(ioStreams), + AggregationRule: map[string]string{}, + } + cmd := &cobra.Command{ + Use: "clusterrole NAME --verb=verb --resource=resource.group [--resource-name=resourcename] [--dry-run]", + DisableFlagsInUseLine: true, + Short: clusterRoleLong, + Long: clusterRoleLong, + Example: clusterRoleExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(c.Complete(f, cmd, args)) + cmdutil.CheckErr(c.Validate()) + cmdutil.CheckErr(c.RunCreateRole()) + }, + } + + c.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddDryRunFlag(cmd) + cmd.Flags().StringSliceVar(&c.Verbs, "verb", c.Verbs, "Verb that applies to the resources contained in the rule") + cmd.Flags().StringSliceVar(&c.NonResourceURLs, "non-resource-url", c.NonResourceURLs, "A partial url that user should have access to.") + cmd.Flags().StringSlice("resource", []string{}, "Resource that the rule applies to") + cmd.Flags().StringArrayVar(&c.ResourceNames, "resource-name", c.ResourceNames, "Resource in the white list that the rule applies to, repeat this flag for multiple items") + cmd.Flags().Var(cliflag.NewMapStringString(&c.AggregationRule), "aggregation-rule", "An aggregation label selector for combining ClusterRoles.") + + return cmd +} + +// Complete completes all the required options +func (c *CreateClusterRoleOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + // Remove duplicate nonResourceURLs + nonResourceURLs := []string{} + for _, n := range c.NonResourceURLs { + if !arrayContains(nonResourceURLs, n) { + nonResourceURLs = append(nonResourceURLs, n) + } + } + c.NonResourceURLs = nonResourceURLs + + return c.CreateRoleOptions.Complete(f, cmd, args) +} + +// Validate makes sure there is no discrepency in CreateClusterRoleOptions +func (c *CreateClusterRoleOptions) Validate() error { + if c.Name == "" { + return fmt.Errorf("name must be specified") + } + + if len(c.AggregationRule) > 0 { + if len(c.NonResourceURLs) > 0 || len(c.Verbs) > 0 || len(c.Resources) > 0 || len(c.ResourceNames) > 0 { + return fmt.Errorf("aggregation rule must be specified without nonResourceURLs, verbs, resources or resourceNames") + } + return nil + } + + // validate verbs. + if len(c.Verbs) == 0 { + return fmt.Errorf("at least one verb must be specified") + } + + if len(c.Resources) == 0 && len(c.NonResourceURLs) == 0 { + return fmt.Errorf("one of resource or nonResourceURL must be specified") + } + + // validate resources + if len(c.Resources) > 0 { + for _, v := range c.Verbs { + if !arrayContains(validResourceVerbs, v) { + return fmt.Errorf("invalid verb: '%s'", v) + } + } + if err := c.validateResource(); err != nil { + return err + } + } + + //validate non-resource-url + if len(c.NonResourceURLs) > 0 { + for _, v := range c.Verbs { + if !arrayContains(validNonResourceVerbs, v) { + return fmt.Errorf("invalid verb: '%s' for nonResourceURL", v) + } + } + + for _, nonResourceURL := range c.NonResourceURLs { + if nonResourceURL == "*" { + continue + } + + if nonResourceURL == "" || !strings.HasPrefix(nonResourceURL, "/") { + return fmt.Errorf("nonResourceURL should start with /") + } + + if strings.ContainsRune(nonResourceURL[:len(nonResourceURL)-1], '*') { + return fmt.Errorf("nonResourceURL only supports wildcard matches when '*' is at the end") + } + } + } + + return nil + +} + +// RunCreateRole creates a new clusterRole +func (c *CreateClusterRoleOptions) RunCreateRole() error { + clusterRole := &rbacv1.ClusterRole{ + // this is ok because we know exactly how we want to be serialized + TypeMeta: metav1.TypeMeta{APIVersion: rbacv1.SchemeGroupVersion.String(), Kind: "ClusterRole"}, + } + clusterRole.Name = c.Name + + var err error + if len(c.AggregationRule) == 0 { + rules, err := generateResourcePolicyRules(c.Mapper, c.Verbs, c.Resources, c.ResourceNames, c.NonResourceURLs) + if err != nil { + return err + } + clusterRole.Rules = rules + } else { + clusterRole.AggregationRule = &rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + { + MatchLabels: c.AggregationRule, + }, + }, + } + } + + // Create ClusterRole. + if !c.DryRun { + clusterRole, err = c.Client.ClusterRoles().Create(clusterRole) + if err != nil { + return err + } + } + + return c.PrintObj(clusterRole) +} diff --git a/pkg/cmd/create/create_clusterrole_test.go b/pkg/cmd/create/create_clusterrole_test.go new file mode 100644 index 000000000..6c5eef0fe --- /dev/null +++ b/pkg/cmd/create/create_clusterrole_test.go @@ -0,0 +1,513 @@ +/* +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 create + +import ( + "testing" + + rbac "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreateClusterRole(t *testing.T) { + clusterRoleName := "my-cluster-role" + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{} + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + tests := map[string]struct { + verbs string + resources string + nonResourceURL string + resourceNames string + aggregationRule string + expectedClusterRole *rbac.ClusterRole + }{ + "test-duplicate-resources": { + verbs: "get,watch,list", + resources: "pods,pods", + expectedClusterRole: &rbac.ClusterRole{ + TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRole"}, + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, + }, + Rules: []rbac.PolicyRule{ + { + Verbs: []string{"get", "watch", "list"}, + Resources: []string{"pods"}, + APIGroups: []string{""}, + ResourceNames: []string{}, + }, + }, + }, + }, + "test-valid-case-with-multiple-apigroups": { + verbs: "get,watch,list", + resources: "pods,deployments.extensions", + expectedClusterRole: &rbac.ClusterRole{ + TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRole"}, + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, + }, + Rules: []rbac.PolicyRule{ + { + Verbs: []string{"get", "watch", "list"}, + Resources: []string{"pods"}, + APIGroups: []string{""}, + ResourceNames: []string{}, + }, + { + Verbs: []string{"get", "watch", "list"}, + Resources: []string{"deployments"}, + APIGroups: []string{"extensions"}, + ResourceNames: []string{}, + }, + }, + }, + }, + "test-non-resource-url": { + verbs: "get", + nonResourceURL: "/logs/,/healthz", + expectedClusterRole: &rbac.ClusterRole{ + TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRole"}, + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, + }, + Rules: []rbac.PolicyRule{ + { + Verbs: []string{"get"}, + NonResourceURLs: []string{"/logs/", "/healthz"}, + }, + }, + }, + }, + "test-resource-and-non-resource-url": { + verbs: "get", + nonResourceURL: "/logs/,/healthz", + resources: "pods", + expectedClusterRole: &rbac.ClusterRole{ + TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRole"}, + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, + }, + Rules: []rbac.PolicyRule{ + { + Verbs: []string{"get"}, + Resources: []string{"pods"}, + APIGroups: []string{""}, + ResourceNames: []string{}, + }, + { + Verbs: []string{"get"}, + NonResourceURLs: []string{"/logs/", "/healthz"}, + }, + }, + }, + }, + "test-aggregation-rules": { + aggregationRule: "foo1=foo2,foo3=foo4", + expectedClusterRole: &rbac.ClusterRole{ + TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRole"}, + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, + }, + AggregationRule: &rbac.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + { + MatchLabels: map[string]string{ + "foo1": "foo2", + "foo3": "foo4", + }, + }, + }, + }, + }, + }, + } + + for name, test := range tests { + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateClusterRole(tf, ioStreams) + cmd.Flags().Set("dry-run", "true") + cmd.Flags().Set("output", "yaml") + cmd.Flags().Set("verb", test.verbs) + cmd.Flags().Set("resource", test.resources) + cmd.Flags().Set("non-resource-url", test.nonResourceURL) + cmd.Flags().Set("aggregation-rule", test.aggregationRule) + if test.resourceNames != "" { + cmd.Flags().Set("resource-name", test.resourceNames) + } + cmd.Run(cmd, []string{clusterRoleName}) + actual := &rbac.ClusterRole{} + if err := runtime.DecodeInto(scheme.Codecs.UniversalDecoder(), buf.Bytes(), actual); err != nil { + t.Log(string(buf.Bytes())) + t.Fatal(err) + } + if !equality.Semantic.DeepEqual(test.expectedClusterRole, actual) { + t.Errorf("%s:\nexpected:\n%#v\nsaw:\n%#v", name, test.expectedClusterRole, actual) + } + } +} + +func TestClusterRoleValidate(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tests := map[string]struct { + clusterRoleOptions *CreateClusterRoleOptions + expectErr bool + }{ + "test-missing-name": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{}, + }, + expectErr: true, + }, + "test-missing-verb": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + }, + }, + expectErr: true, + }, + "test-missing-resource": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"get"}, + }, + }, + expectErr: true, + }, + "test-missing-resource-existing-apigroup": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"get"}, + Resources: []ResourceOptions{ + { + Group: "extensions", + }, + }, + }, + }, + expectErr: true, + }, + "test-missing-resource-existing-subresource": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"get"}, + Resources: []ResourceOptions{ + { + SubResource: "scale", + }, + }, + }, + }, + expectErr: true, + }, + "test-invalid-verb": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"invalid-verb"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + }, + }, + }, + }, + expectErr: true, + }, + "test-nonresource-verb": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"post"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + }, + }, + }, + }, + expectErr: true, + }, + "test-special-verb": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"use"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + }, + }, + }, + }, + expectErr: true, + }, + "test-mix-verbs": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"impersonate", "use"}, + Resources: []ResourceOptions{ + { + Resource: "userextras", + SubResource: "scopes", + }, + }, + }, + }, + expectErr: true, + }, + "test-special-verb-with-wrong-apigroup": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"impersonate"}, + Resources: []ResourceOptions{ + { + Resource: "userextras", + SubResource: "scopes", + Group: "extensions", + }, + }, + }, + }, + expectErr: true, + }, + "test-invalid-resource": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"get"}, + Resources: []ResourceOptions{ + { + Resource: "invalid-resource", + }, + }, + }, + }, + expectErr: true, + }, + "test-resource-name-with-multiple-resources": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"get"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + }, + { + Resource: "deployments", + Group: "extensions", + }, + }, + }, + }, + expectErr: false, + }, + "test-valid-case": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "role-binder", + Verbs: []string{"get", "list", "bind"}, + Resources: []ResourceOptions{ + { + Resource: "roles", + Group: "rbac.authorization.k8s.io", + }, + }, + }, + }, + expectErr: false, + }, + "test-valid-case-with-subresource": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"get", "list"}, + Resources: []ResourceOptions{ + { + Resource: "replicasets", + SubResource: "scale", + }, + }, + }, + }, + expectErr: false, + }, + "test-valid-case-with-additional-resource": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"impersonate"}, + Resources: []ResourceOptions{ + { + Resource: "userextras", + SubResource: "scopes", + Group: "authentication.k8s.io", + }, + }, + }, + }, + expectErr: false, + }, + "test-invalid-empty-non-resource-url": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"create"}, + }, + NonResourceURLs: []string{""}, + }, + expectErr: true, + }, + "test-invalid-non-resource-url": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"create"}, + }, + NonResourceURLs: []string{"logs"}, + }, + expectErr: true, + }, + "test-invalid-non-resource-url-with-*": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"create"}, + }, + NonResourceURLs: []string{"/logs/*/"}, + }, + expectErr: true, + }, + "test-invalid-non-resource-url-with-multiple-*": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"create"}, + }, + NonResourceURLs: []string{"/logs*/*"}, + }, + expectErr: true, + }, + "test-invalid-verb-for-non-resource-url": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"create"}, + }, + NonResourceURLs: []string{"/logs/"}, + }, + expectErr: true, + }, + "test-resource-and-non-resource-url-specified-together": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"get"}, + Resources: []ResourceOptions{ + { + Resource: "replicasets", + SubResource: "scale", + }, + }, + }, + NonResourceURLs: []string{"/logs/", "/logs/*"}, + }, + expectErr: false, + }, + "test-aggregation-rule-with-verb": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Verbs: []string{"get"}, + }, + AggregationRule: map[string]string{"foo-key": "foo-vlue"}, + }, + expectErr: true, + }, + "test-aggregation-rule-with-resource": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + Resources: []ResourceOptions{ + { + Resource: "replicasets", + SubResource: "scale", + }, + }, + }, + AggregationRule: map[string]string{"foo-key": "foo-vlue"}, + }, + expectErr: true, + }, + "test-aggregation-rule-with-no-resource-url": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + }, + NonResourceURLs: []string{"/logs/"}, + AggregationRule: map[string]string{"foo-key": "foo-vlue"}, + }, + expectErr: true, + }, + "test-aggregation-rule": { + clusterRoleOptions: &CreateClusterRoleOptions{ + CreateRoleOptions: &CreateRoleOptions{ + Name: "my-clusterrole", + }, + AggregationRule: map[string]string{"foo-key": "foo-vlue"}, + }, + expectErr: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var err error + test.clusterRoleOptions.Mapper, err = tf.ToRESTMapper() + if err != nil { + t.Fatal(err) + } + err = test.clusterRoleOptions.Validate() + if test.expectErr && err == nil { + t.Errorf("%s: expect error happens, but validate passes.", name) + } + if !test.expectErr && err != nil { + t.Errorf("%s: unexpected error: %v", name, err) + } + }) + } +} diff --git a/pkg/cmd/create/create_clusterrolebinding.go b/pkg/cmd/create/create_clusterrolebinding.go new file mode 100644 index 000000000..452bdde77 --- /dev/null +++ b/pkg/cmd/create/create_clusterrolebinding.go @@ -0,0 +1,102 @@ +/* +Copyright 2016 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 create + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + clusterRoleBindingLong = templates.LongDesc(i18n.T(` + Create a ClusterRoleBinding for a particular ClusterRole.`)) + + clusterRoleBindingExample = templates.Examples(i18n.T(` + # Create a ClusterRoleBinding for user1, user2, and group1 using the cluster-admin ClusterRole + kubectl create clusterrolebinding cluster-admin --clusterrole=cluster-admin --user=user1 --user=user2 --group=group1`)) +) + +// ClusterRoleBindingOpts is returned by NewCmdCreateClusterRoleBinding +type ClusterRoleBindingOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateClusterRoleBinding returns an initialized command instance of ClusterRoleBinding +func NewCmdCreateClusterRoleBinding(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := &ClusterRoleBindingOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "clusterrolebinding NAME --clusterrole=NAME [--user=username] [--group=groupname] [--serviceaccount=namespace:serviceaccountname] [--dry-run]", + DisableFlagsInUseLine: true, + Short: i18n.T("Create a ClusterRoleBinding for a particular ClusterRole"), + Long: clusterRoleBindingLong, + Example: clusterRoleBindingExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Run()) + }, + } + + o.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.ClusterRoleBindingV1GeneratorName) + cmd.Flags().String("clusterrole", "", i18n.T("ClusterRole this ClusterRoleBinding should reference")) + cmd.MarkFlagCustom("clusterrole", "__kubectl_get_resource_clusterrole") + cmd.Flags().StringArray("user", []string{}, "Usernames to bind to the clusterrole") + cmd.Flags().StringArray("group", []string{}, "Groups to bind to the clusterrole") + cmd.Flags().StringArray("serviceaccount", []string{}, "Service accounts to bind to the clusterrole, in the format :") + return cmd +} + +// Complete completes all the required options +func (o *ClusterRoleBindingOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.ClusterRoleBindingV1GeneratorName: + generator = &generateversioned.ClusterRoleBindingGeneratorV1{ + Name: name, + ClusterRole: cmdutil.GetFlagString(cmd, "clusterrole"), + Users: cmdutil.GetFlagStringArray(cmd, "user"), + Groups: cmdutil.GetFlagStringArray(cmd, "group"), + ServiceAccounts: cmdutil.GetFlagStringArray(cmd, "serviceaccount"), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in ClusterRoleBindingOpts instance +func (o *ClusterRoleBindingOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_clusterrolebinding_test.go b/pkg/cmd/create/create_clusterrolebinding_test.go new file mode 100644 index 000000000..facea12a5 --- /dev/null +++ b/pkg/cmd/create/create_clusterrolebinding_test.go @@ -0,0 +1,147 @@ +/* +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 create + +import ( + "bytes" + "io/ioutil" + "net/http" + "net/url" + "reflect" + "testing" + + rbac "k8s.io/api/rbac/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreateClusterRoleBinding(t *testing.T) { + expectBinding := &rbac.ClusterRoleBinding{ + ObjectMeta: v1.ObjectMeta{ + Name: "fake-binding", + }, + TypeMeta: v1.TypeMeta{ + Kind: "ClusterRoleBinding", + APIVersion: "rbac.authorization.k8s.io/v1beta1", + }, + RoleRef: rbac.RoleRef{ + APIGroup: rbac.GroupName, + Kind: "ClusterRole", + Name: "fake-clusterrole", + }, + Subjects: []rbac.Subject{ + { + Kind: rbac.UserKind, + APIGroup: "rbac.authorization.k8s.io", + Name: "fake-user", + }, + { + Kind: rbac.GroupKind, + APIGroup: "rbac.authorization.k8s.io", + Name: "fake-group", + }, + { + Kind: rbac.ServiceAccountKind, + Namespace: "fake-namespace", + Name: "fake-account", + }, + }, + } + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + ns := scheme.Codecs + + info, _ := runtime.SerializerInfoForMediaType(ns.SupportedMediaTypes(), runtime.ContentTypeJSON) + encoder := ns.EncoderForVersion(info.Serializer, groupVersion) + decoder := ns.DecoderToVersion(info.Serializer, groupVersion) + + tf.Client = &ClusterRoleBindingRESTClient{ + RESTClient: &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/clusterrolebindings" && m == "POST": + bodyBits, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatalf("TestCreateClusterRoleBinding error: %v", err) + return nil, nil + } + + if obj, _, err := decoder.Decode(bodyBits, nil, &rbac.ClusterRoleBinding{}); err == nil { + if !reflect.DeepEqual(obj.(*rbac.ClusterRoleBinding), expectBinding) { + t.Fatalf("TestCreateClusterRoleBinding: expected:\n%#v\nsaw:\n%#v", expectBinding, obj.(*rbac.ClusterRoleBinding)) + return nil, nil + } + } else { + t.Fatalf("TestCreateClusterRoleBinding error, could not decode the request body into rbac.ClusterRoleBinding object: %v", err) + return nil, nil + } + + responseBinding := &rbac.ClusterRoleBinding{} + responseBinding.Name = "fake-binding" + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(encoder, responseBinding))))}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + }, + } + + expectedOutput := "clusterrolebinding.rbac.authorization.k8s.io/" + expectBinding.Name + "\n" + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateClusterRoleBinding(tf, ioStreams) + cmd.Flags().Set("clusterrole", "fake-clusterrole") + cmd.Flags().Set("user", "fake-user") + cmd.Flags().Set("group", "fake-group") + cmd.Flags().Set("output", "name") + cmd.Flags().Set("serviceaccount", "fake-namespace:fake-account") + cmd.Run(cmd, []string{"fake-binding"}) + if buf.String() != expectedOutput { + t.Errorf("TestCreateClusterRoleBinding: expected %v\n but got %v\n", expectedOutput, buf.String()) + } +} + +type ClusterRoleBindingRESTClient struct { + *fake.RESTClient +} + +func (c *ClusterRoleBindingRESTClient) Post() *restclient.Request { + config := restclient.ContentConfig{ + ContentType: runtime.ContentTypeJSON, + NegotiatedSerializer: c.NegotiatedSerializer, + } + + info, _ := runtime.SerializerInfoForMediaType(c.NegotiatedSerializer.SupportedMediaTypes(), runtime.ContentTypeJSON) + serializers := restclient.Serializers{ + Encoder: c.NegotiatedSerializer.EncoderForVersion(info.Serializer, schema.GroupVersion{Group: "rbac.authorization.k8s.io", Version: "v1beta1"}), + Decoder: c.NegotiatedSerializer.DecoderToVersion(info.Serializer, schema.GroupVersion{Group: "rbac.authorization.k8s.io", Version: "v1beta1"}), + } + if info.StreamSerializer != nil { + serializers.StreamingSerializer = info.StreamSerializer.Serializer + serializers.Framer = info.StreamSerializer.Framer + } + return restclient.NewRequest(c, "POST", &url.URL{Host: "localhost"}, c.VersionedAPIPath, config, serializers, nil, nil, 0) +} diff --git a/pkg/cmd/create/create_configmap.go b/pkg/cmd/create/create_configmap.go new file mode 100644 index 000000000..dde0a89c9 --- /dev/null +++ b/pkg/cmd/create/create_configmap.go @@ -0,0 +1,123 @@ +/* +Copyright 2016 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 create + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + configMapLong = templates.LongDesc(i18n.T(` + Create a configmap based on a file, directory, or specified literal value. + + A single configmap may package one or more key/value pairs. + + When creating a configmap based on a file, the key will default to the basename of the file, and the value will + default to the file content. If the basename is an invalid key, you may specify an alternate key. + + When creating a configmap based on a directory, each file whose basename is a valid key in the directory will be + packaged into the configmap. Any directory entries except regular files are ignored (e.g. subdirectories, + symlinks, devices, pipes, etc).`)) + + configMapExample = templates.Examples(i18n.T(` + # Create a new configmap named my-config based on folder bar + kubectl create configmap my-config --from-file=path/to/bar + + # Create a new configmap named my-config with specified keys instead of file basenames on disk + kubectl create configmap my-config --from-file=key1=/path/to/bar/file1.txt --from-file=key2=/path/to/bar/file2.txt + + # Create a new configmap named my-config with key1=config1 and key2=config2 + kubectl create configmap my-config --from-literal=key1=config1 --from-literal=key2=config2 + + # Create a new configmap named my-config from the key=value pairs in the file + kubectl create configmap my-config --from-file=path/to/bar + + # Create a new configmap named my-config from an env file + kubectl create configmap my-config --from-env-file=path/to/bar.env`)) +) + +// ConfigMapOpts holds properties for create configmap sub-command +type ConfigMapOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateConfigMap initializes and returns ConfigMapOpts +func NewCmdCreateConfigMap(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &ConfigMapOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "configmap NAME [--from-file=[key=]source] [--from-literal=key1=value1] [--dry-run]", + DisableFlagsInUseLine: true, + Aliases: []string{"cm"}, + Short: i18n.T("Create a configmap from a local file, directory or literal value"), + Long: configMapLong, + Example: configMapExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.ConfigMapV1GeneratorName) + cmd.Flags().StringSlice("from-file", []string{}, "Key file can be specified using its file path, in which case file basename will be used as configmap key, or optionally with a key and file path, in which case the given key will be used. Specifying a directory will iterate each named file in the directory whose basename is a valid configmap key.") + cmd.Flags().StringArray("from-literal", []string{}, "Specify a key and literal value to insert in configmap (i.e. mykey=somevalue)") + cmd.Flags().String("from-env-file", "", "Specify the path to a file to read lines of key=val pairs to create a configmap (i.e. a Docker .env file).") + cmd.Flags().Bool("append-hash", false, "Append a hash of the configmap to its name.") + return cmd +} + +// Complete completes all the required options +func (o *ConfigMapOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.ConfigMapV1GeneratorName: + generator = &generateversioned.ConfigMapGeneratorV1{ + Name: name, + FileSources: cmdutil.GetFlagStringSlice(cmd, "from-file"), + LiteralSources: cmdutil.GetFlagStringArray(cmd, "from-literal"), + EnvFileSource: cmdutil.GetFlagString(cmd, "from-env-file"), + AppendHash: cmdutil.GetFlagBool(cmd, "append-hash"), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run performs the execution of 'create' sub command options +func (o *ConfigMapOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_configmap_test.go b/pkg/cmd/create/create_configmap_test.go new file mode 100644 index 000000000..e9114a17c --- /dev/null +++ b/pkg/cmd/create/create_configmap_test.go @@ -0,0 +1,61 @@ +/* +Copyright 2016 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 create + +import ( + "net/http" + "testing" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreateConfigMap(t *testing.T) { + configMap := &v1.ConfigMap{} + configMap.Name = "my-configmap" + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/configmaps" && m == "POST": + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, configMap)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateConfigMap(tf, ioStreams) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{configMap.Name}) + expectedOutput := "configmap/" + configMap.Name + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} diff --git a/pkg/cmd/create/create_cronjob.go b/pkg/cmd/create/create_cronjob.go new file mode 100644 index 000000000..c916ed126 --- /dev/null +++ b/pkg/cmd/create/create_cronjob.go @@ -0,0 +1,205 @@ +/* +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 create + +import ( + "fmt" + + "github.com/spf13/cobra" + + batchv1 "k8s.io/api/batch/v1" + batchv1beta1 "k8s.io/api/batch/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + batchv1beta1client "k8s.io/client-go/kubernetes/typed/batch/v1beta1" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + cronjobLong = templates.LongDesc(` + Create a cronjob with the specified name.`) + + cronjobExample = templates.Examples(` + # Create a cronjob + kubectl create cronjob my-job --image=busybox + + # Create a cronjob with command + kubectl create cronjob my-job --image=busybox -- date + + # Create a cronjob with schedule + kubectl create cronjob test-job --image=busybox --schedule="*/1 * * * *"`) +) + +type CreateCronJobOptions struct { + PrintFlags *genericclioptions.PrintFlags + + PrintObj func(obj runtime.Object) error + + Name string + Image string + Schedule string + Command []string + Restart string + + Namespace string + Client batchv1beta1client.BatchV1beta1Interface + DryRun bool + Builder *resource.Builder + Cmd *cobra.Command + + genericclioptions.IOStreams +} + +func NewCreateCronJobOptions(ioStreams genericclioptions.IOStreams) *CreateCronJobOptions { + return &CreateCronJobOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + IOStreams: ioStreams, + } +} + +// NewCmdCreateCronJob is a command to to create CronJobs. +func NewCmdCreateCronJob(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewCreateCronJobOptions(ioStreams) + cmd := &cobra.Command{ + Use: "cronjob NAME --image=image --schedule='0/5 * * * ?' -- [COMMAND] [args...]", + Aliases: []string{"cj"}, + Short: cronjobLong, + Long: cronjobLong, + Example: cronjobExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run()) + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddDryRunFlag(cmd) + cmd.Flags().StringVar(&o.Image, "image", o.Image, "Image name to run.") + cmd.Flags().StringVar(&o.Schedule, "schedule", o.Schedule, "A schedule in the Cron format the job should be run with.") + cmd.Flags().StringVar(&o.Restart, "restart", o.Restart, "job's restart policy. supported values: OnFailure, Never") + + return cmd +} + +func (o *CreateCronJobOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + o.Name = name + if len(args) > 1 { + o.Command = args[1:] + } + if len(o.Restart) == 0 { + o.Restart = "OnFailure" + } + + clientConfig, err := f.ToRESTConfig() + if err != nil { + return err + } + o.Client, err = batchv1beta1client.NewForConfig(clientConfig) + if err != nil { + return err + } + + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + o.Builder = f.NewBuilder() + o.Cmd = cmd + + o.DryRun = cmdutil.GetDryRunFlag(cmd) + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = func(obj runtime.Object) error { + return printer.PrintObj(obj, o.Out) + } + + return nil +} + +func (o *CreateCronJobOptions) Validate() error { + if len(o.Image) == 0 { + return fmt.Errorf("--image must be specified") + } + if len(o.Schedule) == 0 { + return fmt.Errorf("--schedule must be specified") + } + return nil +} + +func (o *CreateCronJobOptions) Run() error { + var cronjob *batchv1beta1.CronJob + cronjob = o.createCronJob() + + if !o.DryRun { + var err error + cronjob, err = o.Client.CronJobs(o.Namespace).Create(cronjob) + if err != nil { + return fmt.Errorf("failed to create cronjob: %v", err) + } + } + + return o.PrintObj(cronjob) +} + +func (o *CreateCronJobOptions) createCronJob() *batchv1beta1.CronJob { + return &batchv1beta1.CronJob{ + TypeMeta: metav1.TypeMeta{APIVersion: batchv1beta1.SchemeGroupVersion.String(), Kind: "CronJob"}, + ObjectMeta: metav1.ObjectMeta{ + Name: o.Name, + }, + Spec: batchv1beta1.CronJobSpec{ + Schedule: o.Schedule, + JobTemplate: batchv1beta1.JobTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: o.Name, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: o.Name, + Image: o.Image, + Command: o.Command, + }, + }, + RestartPolicy: corev1.RestartPolicy(o.Restart), + }, + }, + }, + }, + }, + } +} diff --git a/pkg/cmd/create/create_cronjob_test.go b/pkg/cmd/create/create_cronjob_test.go new file mode 100644 index 000000000..cf0f911da --- /dev/null +++ b/pkg/cmd/create/create_cronjob_test.go @@ -0,0 +1,121 @@ +/* +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 create + +import ( + "testing" + + batchv1 "k8s.io/api/batch/v1" + batchv1beta1 "k8s.io/api/batch/v1beta1" + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestCreateCronJob(t *testing.T) { + cronjobName := "test-job" + tests := map[string]struct { + image string + command []string + schedule string + restart string + expected *batchv1beta1.CronJob + }{ + "just image and OnFailure restart policy": { + image: "busybox", + schedule: "0/5 * * * ?", + restart: "OnFailure", + expected: &batchv1beta1.CronJob{ + TypeMeta: metav1.TypeMeta{APIVersion: batchv1beta1.SchemeGroupVersion.String(), Kind: "CronJob"}, + ObjectMeta: metav1.ObjectMeta{ + Name: cronjobName, + }, + Spec: batchv1beta1.CronJobSpec{ + Schedule: "0/5 * * * ?", + JobTemplate: batchv1beta1.JobTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: cronjobName, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: cronjobName, + Image: "busybox", + }, + }, + RestartPolicy: corev1.RestartPolicyOnFailure, + }, + }, + }, + }, + }, + }, + }, + "image, command , schedule and Never restart policy": { + image: "busybox", + command: []string{"date"}, + schedule: "0/5 * * * ?", + restart: "Never", + expected: &batchv1beta1.CronJob{ + TypeMeta: metav1.TypeMeta{APIVersion: batchv1beta1.SchemeGroupVersion.String(), Kind: "CronJob"}, + ObjectMeta: metav1.ObjectMeta{ + Name: cronjobName, + }, + Spec: batchv1beta1.CronJobSpec{ + Schedule: "0/5 * * * ?", + JobTemplate: batchv1beta1.JobTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: cronjobName, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: cronjobName, + Image: "busybox", + Command: []string{"date"}, + }, + }, + RestartPolicy: corev1.RestartPolicyNever, + }, + }, + }, + }, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + o := &CreateCronJobOptions{ + Name: cronjobName, + Image: tc.image, + Command: tc.command, + Schedule: tc.schedule, + Restart: tc.restart, + } + cronjob := o.createCronJob() + if !apiequality.Semantic.DeepEqual(cronjob, tc.expected) { + t.Errorf("expected:\n%#v\ngot:\n%#v", tc.expected, cronjob) + } + }) + } +} diff --git a/pkg/cmd/create/create_deployment.go b/pkg/cmd/create/create_deployment.go new file mode 100644 index 000000000..0fb36a6ea --- /dev/null +++ b/pkg/cmd/create/create_deployment.go @@ -0,0 +1,155 @@ +/* +Copyright 2016 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 create + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + deploymentLong = templates.LongDesc(i18n.T(` + Create a deployment with the specified name.`)) + + deploymentExample = templates.Examples(i18n.T(` + # Create a new deployment named my-dep that runs the busybox image. + kubectl create deployment my-dep --image=busybox`)) +) + +// DeploymentOpts is returned by NewCmdCreateDeployment +type DeploymentOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateDeployment is a macro command to create a new deployment. +// This command is better known to users as `kubectl create deployment`. +// Note that this command overlaps significantly with the `kubectl run` command. +func NewCmdCreateDeployment(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &DeploymentOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "deployment NAME --image=image [--dry-run]", + DisableFlagsInUseLine: true, + Aliases: []string{"deploy"}, + Short: i18n.T("Create a deployment with the specified name."), + Long: deploymentLong, + Example: deploymentExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, "") + cmd.Flags().StringSlice("image", []string{}, "Image name to run.") + cmd.MarkFlagRequired("image") + return cmd +} + +// generatorFromName returns the appropriate StructuredGenerator based on the +// generatorName. If the generatorName is unrecognized, then return (nil, +// false). +func generatorFromName( + generatorName string, + imageNames []string, + deploymentName string, +) (generate.StructuredGenerator, bool) { + + switch generatorName { + case generateversioned.DeploymentBasicAppsV1GeneratorName: + generator := &generateversioned.DeploymentBasicAppsGeneratorV1{ + BaseDeploymentGenerator: generateversioned.BaseDeploymentGenerator{ + Name: deploymentName, + Images: imageNames, + }, + } + return generator, true + + case generateversioned.DeploymentBasicAppsV1Beta1GeneratorName: + generator := &generateversioned.DeploymentBasicAppsGeneratorV1Beta1{ + BaseDeploymentGenerator: generateversioned.BaseDeploymentGenerator{ + Name: deploymentName, + Images: imageNames, + }, + } + return generator, true + + case generateversioned.DeploymentBasicV1Beta1GeneratorName: + generator := &generateversioned.DeploymentBasicGeneratorV1{ + BaseDeploymentGenerator: generateversioned.BaseDeploymentGenerator{ + Name: deploymentName, + Images: imageNames, + }, + } + return generator, true + } + + return nil, false +} + +// Complete completes all the options +func (o *DeploymentOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + clientset, err := f.KubernetesClientSet() + if err != nil { + return err + } + + generatorName := cmdutil.GetFlagString(cmd, "generator") + + if len(generatorName) == 0 { + generatorName = generateversioned.DeploymentBasicAppsV1GeneratorName + generatorNameTemp, err := generateversioned.FallbackGeneratorNameIfNecessary(generatorName, clientset.Discovery(), o.CreateSubcommandOptions.ErrOut) + if err != nil { + return err + } + if generatorNameTemp != generatorName { + cmdutil.Warning(o.CreateSubcommandOptions.ErrOut, generatorName, generatorNameTemp) + } else { + generatorName = generatorNameTemp + } + } + + imageNames := cmdutil.GetFlagStringSlice(cmd, "image") + generator, ok := generatorFromName(generatorName, imageNames, name) + if !ok { + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run performs the execution of 'create deployment' sub command +func (o *DeploymentOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_deployment_test.go b/pkg/cmd/create/create_deployment_test.go new file mode 100644 index 000000000..7f70184eb --- /dev/null +++ b/pkg/cmd/create/create_deployment_test.go @@ -0,0 +1,155 @@ +/* +Copyright 2016 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 create + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func Test_generatorFromName(t *testing.T) { + const ( + nonsenseName = "not-a-real-generator-name" + basicName = generateversioned.DeploymentBasicV1Beta1GeneratorName + basicAppsV1Beta1Name = generateversioned.DeploymentBasicAppsV1Beta1GeneratorName + basicAppsV1Name = generateversioned.DeploymentBasicAppsV1GeneratorName + deploymentName = "deployment-name" + ) + imageNames := []string{"image-1", "image-2"} + + generator, ok := generatorFromName(nonsenseName, imageNames, deploymentName) + assert.Nil(t, generator) + assert.False(t, ok) + + generator, ok = generatorFromName(basicName, imageNames, deploymentName) + assert.True(t, ok) + + { + expectedGenerator := &generateversioned.DeploymentBasicGeneratorV1{ + BaseDeploymentGenerator: generateversioned.BaseDeploymentGenerator{ + Name: deploymentName, + Images: imageNames, + }, + } + assert.Equal(t, expectedGenerator, generator) + } + + generator, ok = generatorFromName(basicAppsV1Beta1Name, imageNames, deploymentName) + assert.True(t, ok) + + { + expectedGenerator := &generateversioned.DeploymentBasicAppsGeneratorV1Beta1{ + BaseDeploymentGenerator: generateversioned.BaseDeploymentGenerator{ + Name: deploymentName, + Images: imageNames, + }, + } + assert.Equal(t, expectedGenerator, generator) + } + + generator, ok = generatorFromName(basicAppsV1Name, imageNames, deploymentName) + assert.True(t, ok) + + { + expectedGenerator := &generateversioned.DeploymentBasicAppsGeneratorV1{ + BaseDeploymentGenerator: generateversioned.BaseDeploymentGenerator{ + Name: deploymentName, + Images: imageNames, + }, + } + assert.Equal(t, expectedGenerator, generator) + } +} + +func TestCreateDeployment(t *testing.T) { + depName := "jonny-dep" + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + ns := scheme.Codecs + fakeDiscovery := "{\"kind\":\"APIResourceList\",\"apiVersion\":\"v1\",\"groupVersion\":\"apps/v1\",\"resources\":[{\"name\":\"deployments\",\"singularName\":\"\",\"namespaced\":true,\"kind\":\"Deployment\",\"verbs\":[\"create\",\"delete\",\"deletecollection\",\"get\",\"list\",\"patch\",\"update\",\"watch\"],\"shortNames\":[\"deploy\"],\"categories\":[\"all\"]}]}" + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewBuffer([]byte(fakeDiscovery))), + }, nil + }), + } + tf.ClientConfigVal = &restclient.Config{} + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateDeployment(tf, ioStreams) + cmd.Flags().Set("dry-run", "true") + cmd.Flags().Set("output", "name") + cmd.Flags().Set("image", "hollywood/jonny.depp:v2") + cmd.Run(cmd, []string{depName}) + expectedOutput := "deployment.apps/" + depName + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} + +func TestCreateDeploymentNoImage(t *testing.T) { + depName := "jonny-dep" + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + ns := scheme.Codecs + fakeDiscovery := "{\"kind\":\"APIResourceList\",\"apiVersion\":\"v1\",\"groupVersion\":\"apps/v1\",\"resources\":[{\"name\":\"deployments\",\"singularName\":\"\",\"namespaced\":true,\"kind\":\"Deployment\",\"verbs\":[\"create\",\"delete\",\"deletecollection\",\"get\",\"list\",\"patch\",\"update\",\"watch\"],\"shortNames\":[\"deploy\"],\"categories\":[\"all\"]}]}" + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewBuffer([]byte(fakeDiscovery))), + }, nil + }), + } + tf.ClientConfigVal = &restclient.Config{} + + ioStreams := genericclioptions.NewTestIOStreamsDiscard() + cmd := NewCmdCreateDeployment(tf, ioStreams) + cmd.Flags().Set("output", "name") + options := &DeploymentOpts{ + CreateSubcommandOptions: &CreateSubcommandOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + DryRun: true, + IOStreams: ioStreams, + }, + } + + err := options.Complete(tf, cmd, []string{depName}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = options.Run() + assert.Error(t, err, "at least one image must be specified") +} diff --git a/pkg/cmd/create/create_job.go b/pkg/cmd/create/create_job.go new file mode 100644 index 000000000..0dda88141 --- /dev/null +++ b/pkg/cmd/create/create_job.go @@ -0,0 +1,248 @@ +/* +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 create + +import ( + "fmt" + + "github.com/spf13/cobra" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + batchv1beta1 "k8s.io/api/batch/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + batchv1client "k8s.io/client-go/kubernetes/typed/batch/v1" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + jobLong = templates.LongDesc(i18n.T(` + Create a job with the specified name.`)) + + jobExample = templates.Examples(i18n.T(` + # Create a job + kubectl create job my-job --image=busybox + + # Create a job with command + kubectl create job my-job --image=busybox -- date + + # Create a job from a CronJob named "a-cronjob" + kubectl create job test-job --from=cronjob/a-cronjob`)) +) + +// CreateJobOptions is the command line options for 'create job' +type CreateJobOptions struct { + PrintFlags *genericclioptions.PrintFlags + + PrintObj func(obj runtime.Object) error + + Name string + Image string + From string + Command []string + + Namespace string + Client batchv1client.BatchV1Interface + DryRun bool + Builder *resource.Builder + Cmd *cobra.Command + + genericclioptions.IOStreams +} + +// NewCreateJobOptions initializes and returns new CreateJobOptions instance +func NewCreateJobOptions(ioStreams genericclioptions.IOStreams) *CreateJobOptions { + return &CreateJobOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + IOStreams: ioStreams, + } +} + +// NewCmdCreateJob is a command to ease creating Jobs from CronJobs. +func NewCmdCreateJob(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewCreateJobOptions(ioStreams) + cmd := &cobra.Command{ + Use: "job NAME --image=image [--from=cronjob/name] -- [COMMAND] [args...]", + Short: jobLong, + Long: jobLong, + Example: jobExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run()) + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddDryRunFlag(cmd) + cmd.Flags().StringVar(&o.Image, "image", o.Image, "Image name to run.") + cmd.Flags().StringVar(&o.From, "from", o.From, "The name of the resource to create a Job from (only cronjob is supported).") + + return cmd +} + +// Complete completes all the required options +func (o *CreateJobOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + o.Name = name + if len(args) > 1 { + o.Command = args[1:] + } + + clientConfig, err := f.ToRESTConfig() + if err != nil { + return err + } + o.Client, err = batchv1client.NewForConfig(clientConfig) + if err != nil { + return err + } + + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + o.Builder = f.NewBuilder() + o.Cmd = cmd + + o.DryRun = cmdutil.GetDryRunFlag(cmd) + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = func(obj runtime.Object) error { + return printer.PrintObj(obj, o.Out) + } + + return nil +} + +// Validate makes sure provided values and valid Job options +func (o *CreateJobOptions) Validate() error { + if (len(o.Image) == 0 && len(o.From) == 0) || (len(o.Image) != 0 && len(o.From) != 0) { + return fmt.Errorf("either --image or --from must be specified") + } + if o.Command != nil && len(o.Command) != 0 && len(o.From) != 0 { + return fmt.Errorf("cannot specify --from and command") + } + return nil +} + +// Run performs the execution of 'create job' sub command +func (o *CreateJobOptions) Run() error { + var job *batchv1.Job + if len(o.Image) > 0 { + job = o.createJob() + } else { + infos, err := o.Builder. + Unstructured(). + NamespaceParam(o.Namespace).DefaultNamespace(). + ResourceTypeOrNameArgs(false, o.From). + Flatten(). + Latest(). + Do(). + Infos() + if err != nil { + return err + } + if len(infos) != 1 { + return fmt.Errorf("from must be an existing cronjob") + } + + uncastVersionedObj, err := scheme.Scheme.ConvertToVersion(infos[0].Object, batchv1beta1.SchemeGroupVersion) + if err != nil { + return fmt.Errorf("from must be an existing cronjob: %v", err) + } + cronJob, ok := uncastVersionedObj.(*batchv1beta1.CronJob) + if !ok { + return fmt.Errorf("from must be an existing cronjob") + } + + job = o.createJobFromCronJob(cronJob) + } + if !o.DryRun { + var err error + job, err = o.Client.Jobs(o.Namespace).Create(job) + if err != nil { + return fmt.Errorf("failed to create job: %v", err) + } + } + + return o.PrintObj(job) +} + +func (o *CreateJobOptions) createJob() *batchv1.Job { + return &batchv1.Job{ + // this is ok because we know exactly how we want to be serialized + TypeMeta: metav1.TypeMeta{APIVersion: batchv1.SchemeGroupVersion.String(), Kind: "Job"}, + ObjectMeta: metav1.ObjectMeta{ + Name: o.Name, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: o.Name, + Image: o.Image, + Command: o.Command, + }, + }, + RestartPolicy: corev1.RestartPolicyNever, + }, + }, + }, + } +} + +func (o *CreateJobOptions) createJobFromCronJob(cronJob *batchv1beta1.CronJob) *batchv1.Job { + annotations := make(map[string]string) + annotations["cronjob.kubernetes.io/instantiate"] = "manual" + for k, v := range cronJob.Spec.JobTemplate.Annotations { + annotations[k] = v + } + + return &batchv1.Job{ + // this is ok because we know exactly how we want to be serialized + TypeMeta: metav1.TypeMeta{APIVersion: batchv1.SchemeGroupVersion.String(), Kind: "Job"}, + ObjectMeta: metav1.ObjectMeta{ + Name: o.Name, + Annotations: annotations, + Labels: cronJob.Spec.JobTemplate.Labels, + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(cronJob, appsv1.SchemeGroupVersion.WithKind("CronJob")), + }, + }, + Spec: cronJob.Spec.JobTemplate.Spec, + } +} diff --git a/pkg/cmd/create/create_job_test.go b/pkg/cmd/create/create_job_test.go new file mode 100644 index 000000000..585d45298 --- /dev/null +++ b/pkg/cmd/create/create_job_test.go @@ -0,0 +1,196 @@ +/* +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 create + +import ( + "strings" + "testing" + + apps "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + batchv1beta1 "k8s.io/api/batch/v1beta1" + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestCreateJobValidation(t *testing.T) { + tests := map[string]struct { + image string + command []string + from string + expected string + }{ + "empty flags": { + expected: "--image or --from must be specified", + }, + "both image and from specified": { + image: "my-image", + from: "cronjob/xyz", + expected: "--image or --from must be specified", + }, + "from and command specified": { + from: "cronjob/xyz", + command: []string{"test", "command"}, + expected: "cannot specify --from and command", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + o := &CreateJobOptions{ + Image: tc.image, + From: tc.from, + Command: tc.command, + } + + err := o.Validate() + if err != nil && !strings.Contains(err.Error(), tc.expected) { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestCreateJob(t *testing.T) { + jobName := "test-job" + tests := map[string]struct { + image string + command []string + expected *batchv1.Job + }{ + "just image": { + image: "busybox", + expected: &batchv1.Job{ + TypeMeta: metav1.TypeMeta{APIVersion: batchv1.SchemeGroupVersion.String(), Kind: "Job"}, + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: jobName, + Image: "busybox", + }, + }, + RestartPolicy: corev1.RestartPolicyNever, + }, + }, + }, + }, + }, + "image and command": { + image: "busybox", + command: []string{"date"}, + expected: &batchv1.Job{ + TypeMeta: metav1.TypeMeta{APIVersion: batchv1.SchemeGroupVersion.String(), Kind: "Job"}, + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: jobName, + Image: "busybox", + Command: []string{"date"}, + }, + }, + RestartPolicy: corev1.RestartPolicyNever, + }, + }, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + o := &CreateJobOptions{ + Name: jobName, + Image: tc.image, + Command: tc.command, + } + job := o.createJob() + if !apiequality.Semantic.DeepEqual(job, tc.expected) { + t.Errorf("expected:\n%#v\ngot:\n%#v", tc.expected, job) + } + }) + } +} + +func TestCreateJobFromCronJob(t *testing.T) { + jobName := "test-job" + cronJob := &batchv1beta1.CronJob{ + Spec: batchv1beta1.CronJobSpec{ + JobTemplate: batchv1beta1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Image: "test-image"}, + }, + RestartPolicy: corev1.RestartPolicyNever, + }, + }, + }, + }, + }, + } + tests := map[string]struct { + from *batchv1beta1.CronJob + expected *batchv1.Job + }{ + "from CronJob": { + from: cronJob, + expected: &batchv1.Job{ + TypeMeta: metav1.TypeMeta{APIVersion: batchv1.SchemeGroupVersion.String(), Kind: "Job"}, + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + Annotations: map[string]string{"cronjob.kubernetes.io/instantiate": "manual"}, + OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(cronJob, apps.SchemeGroupVersion.WithKind("CronJob"))}, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Image: "test-image"}, + }, + RestartPolicy: corev1.RestartPolicyNever, + }, + }, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + o := &CreateJobOptions{ + Name: jobName, + } + job := o.createJobFromCronJob(tc.from) + + if !apiequality.Semantic.DeepEqual(job, tc.expected) { + t.Errorf("expected:\n%#v\ngot:\n%#v", tc.expected, job) + } + }) + } +} diff --git a/pkg/cmd/create/create_namespace.go b/pkg/cmd/create/create_namespace.go new file mode 100644 index 000000000..00f58aa0b --- /dev/null +++ b/pkg/cmd/create/create_namespace.go @@ -0,0 +1,93 @@ +/* +Copyright 2015 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 create + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + namespaceLong = templates.LongDesc(i18n.T(` + Create a namespace with the specified name.`)) + + namespaceExample = templates.Examples(i18n.T(` + # Create a new namespace named my-namespace + kubectl create namespace my-namespace`)) +) + +// NamespaceOpts is the options for 'create namespare' sub command +type NamespaceOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateNamespace is a macro command to create a new namespace +func NewCmdCreateNamespace(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &NamespaceOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "namespace NAME [--dry-run]", + DisableFlagsInUseLine: true, + Aliases: []string{"ns"}, + Short: i18n.T("Create a namespace with the specified name"), + Long: namespaceLong, + Example: namespaceExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.NamespaceV1GeneratorName) + + return cmd +} + +// Complete completes all the required options +func (o *NamespaceOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.NamespaceV1GeneratorName: + generator = &generateversioned.NamespaceGeneratorV1{Name: name} + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in NamespaceOpts instance +func (o *NamespaceOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_namespace_test.go b/pkg/cmd/create/create_namespace_test.go new file mode 100644 index 000000000..23ed24929 --- /dev/null +++ b/pkg/cmd/create/create_namespace_test.go @@ -0,0 +1,61 @@ +/* +Copyright 2014 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 create + +import ( + "net/http" + "testing" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreateNamespace(t *testing.T) { + namespaceObject := &v1.Namespace{} + namespaceObject.Name = "my-namespace" + tf := cmdtesting.NewTestFactory() + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces" && m == "POST": + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, namespaceObject)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateNamespace(tf, ioStreams) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{namespaceObject.Name}) + expectedOutput := "namespace/" + namespaceObject.Name + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} diff --git a/pkg/cmd/create/create_pdb.go b/pkg/cmd/create/create_pdb.go new file mode 100644 index 000000000..fd321bff8 --- /dev/null +++ b/pkg/cmd/create/create_pdb.go @@ -0,0 +1,112 @@ +/* +Copyright 2016 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 create + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + pdbLong = templates.LongDesc(i18n.T(` + Create a pod disruption budget with the specified name, selector, and desired minimum available pods`)) + + pdbExample = templates.Examples(i18n.T(` + # Create a pod disruption budget named my-pdb that will select all pods with the app=rails label + # and require at least one of them being available at any point in time. + kubectl create poddisruptionbudget my-pdb --selector=app=rails --min-available=1 + + # Create a pod disruption budget named my-pdb that will select all pods with the app=nginx label + # and require at least half of the pods selected to be available at any point in time. + kubectl create pdb my-pdb --selector=app=nginx --min-available=50%`)) +) + +// PodDisruptionBudgetOpts holds the command-line options for poddisruptionbudget sub command +type PodDisruptionBudgetOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreatePodDisruptionBudget is a macro command to create a new pod disruption budget. +func NewCmdCreatePodDisruptionBudget(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &PodDisruptionBudgetOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "poddisruptionbudget NAME --selector=SELECTOR --min-available=N [--dry-run]", + DisableFlagsInUseLine: true, + Aliases: []string{"pdb"}, + Short: i18n.T("Create a pod disruption budget with the specified name."), + Long: pdbLong, + Example: pdbExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.PodDisruptionBudgetV2GeneratorName) + + cmd.Flags().String("min-available", "", i18n.T("The minimum number or percentage of available pods this budget requires.")) + cmd.Flags().String("max-unavailable", "", i18n.T("The maximum number or percentage of unavailable pods this budget requires.")) + cmd.Flags().String("selector", "", i18n.T("A label selector to use for this budget. Only equality-based selector requirements are supported.")) + return cmd +} + +// Complete completes all the required options +func (o *PodDisruptionBudgetOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.PodDisruptionBudgetV1GeneratorName: + generator = &generateversioned.PodDisruptionBudgetV1Generator{ + Name: name, + MinAvailable: cmdutil.GetFlagString(cmd, "min-available"), + Selector: cmdutil.GetFlagString(cmd, "selector"), + } + case generateversioned.PodDisruptionBudgetV2GeneratorName: + generator = &generateversioned.PodDisruptionBudgetV2Generator{ + Name: name, + MinAvailable: cmdutil.GetFlagString(cmd, "min-available"), + MaxUnavailable: cmdutil.GetFlagString(cmd, "max-unavailable"), + Selector: cmdutil.GetFlagString(cmd, "selector"), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in PodDisruptionBudgetOpts instance +func (o *PodDisruptionBudgetOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_pdb_test.go b/pkg/cmd/create/create_pdb_test.go new file mode 100644 index 000000000..8da5917a5 --- /dev/null +++ b/pkg/cmd/create/create_pdb_test.go @@ -0,0 +1,85 @@ +/* +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 create + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreatePdb(t *testing.T) { + pdbName := "my-pdb" + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "policy", Version: "v1beta1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(&bytes.Buffer{}), + }, nil + }), + } + tf.ClientConfigVal = &restclient.Config{} + + outputFormat := "name" + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreatePodDisruptionBudget(tf, ioStreams) + cmd.Flags().Set("min-available", "1") + cmd.Flags().Set("selector", "app=rails") + cmd.Flags().Set("dry-run", "true") + cmd.Flags().Set("output", outputFormat) + + printFlags := genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme) + printFlags.OutputFormat = &outputFormat + + options := &PodDisruptionBudgetOpts{ + CreateSubcommandOptions: &CreateSubcommandOptions{ + PrintFlags: printFlags, + Name: pdbName, + IOStreams: ioStreams, + }, + } + err := options.Complete(tf, cmd, []string{pdbName}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = options.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedOutput := "poddisruptionbudget.policy/" + pdbName + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} diff --git a/pkg/cmd/create/create_priorityclass.go b/pkg/cmd/create/create_priorityclass.go new file mode 100644 index 000000000..c50d343df --- /dev/null +++ b/pkg/cmd/create/create_priorityclass.go @@ -0,0 +1,110 @@ +/* +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 create + +import ( + "github.com/spf13/cobra" + + apiv1 "k8s.io/api/core/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + pcLong = templates.LongDesc(i18n.T(` + Create a priorityclass with the specified name, value, globalDefault and description`)) + + pcExample = templates.Examples(i18n.T(` + # Create a priorityclass named high-priority + kubectl create priorityclass high-priority --value=1000 --description="high priority" + + # Create a priorityclass named default-priority that considered as the global default priority + kubectl create priorityclass default-priority --value=1000 --global-default=true --description="default priority" + + # Create a priorityclass named high-priority that can not preempt pods with lower priority + kubectl create priorityclass high-priority --value=1000 --description="high priority" --preemption-policy="Never"`)) +) + +// PriorityClassOpts holds the options for 'create priorityclass' sub command +type PriorityClassOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreatePriorityClass is a macro command to create a new priorityClass. +func NewCmdCreatePriorityClass(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &PriorityClassOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "priorityclass NAME --value=VALUE --global-default=BOOL [--dry-run]", + DisableFlagsInUseLine: true, + Aliases: []string{"pc"}, + Short: i18n.T("Create a priorityclass with the specified name."), + Long: pcLong, + Example: pcExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.PriorityClassV1GeneratorName) + + cmd.Flags().Int32("value", 0, i18n.T("the value of this priority class.")) + cmd.Flags().Bool("global-default", false, i18n.T("global-default specifies whether this PriorityClass should be considered as the default priority.")) + cmd.Flags().String("description", "", i18n.T("description is an arbitrary string that usually provides guidelines on when this priority class should be used.")) + cmd.Flags().String("preemption-policy", "", i18n.T("preemption-policy is the policy for preempting pods with lower priority.")) + return cmd +} + +// Complete completes all the required options +func (o *PriorityClassOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.PriorityClassV1GeneratorName: + generator = &generateversioned.PriorityClassV1Generator{ + Name: name, + Value: cmdutil.GetFlagInt32(cmd, "value"), + GlobalDefault: cmdutil.GetFlagBool(cmd, "global-default"), + Description: cmdutil.GetFlagString(cmd, "description"), + PreemptionPolicy: apiv1.PreemptionPolicy(cmdutil.GetFlagString(cmd, "preemption-policy")), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in the PriorityClassOpts instance +func (o *PriorityClassOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_priorityclass_test.go b/pkg/cmd/create/create_priorityclass_test.go new file mode 100644 index 000000000..26464ec69 --- /dev/null +++ b/pkg/cmd/create/create_priorityclass_test.go @@ -0,0 +1,87 @@ +/* +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 create + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreatePriorityClass(t *testing.T) { + pcName := "my-pc" + tf := cmdtesting.NewTestFactory() + defer tf.Cleanup() + + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "scheduling.k8s.io", Version: "v1beta1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(&bytes.Buffer{}), + }, nil + }), + } + tf.ClientConfigVal = &restclient.Config{} + + outputFormat := "name" + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreatePriorityClass(tf, ioStreams) + cmd.Flags().Set("value", "1000") + cmd.Flags().Set("global-default", "true") + cmd.Flags().Set("description", "my priority") + cmd.Flags().Set("dry-run", "true") + cmd.Flags().Set("output", outputFormat) + cmd.Flags().Set("preemption-policy", "Never") + + printFlags := genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme) + printFlags.OutputFormat = &outputFormat + + options := &PriorityClassOpts{ + CreateSubcommandOptions: &CreateSubcommandOptions{ + PrintFlags: printFlags, + Name: pcName, + IOStreams: ioStreams, + }, + } + err := options.Complete(tf, cmd, []string{pcName}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = options.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedOutput := "priorityclass.scheduling.k8s.io/" + pcName + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} diff --git a/pkg/cmd/create/create_quota.go b/pkg/cmd/create/create_quota.go new file mode 100644 index 000000000..586d1be85 --- /dev/null +++ b/pkg/cmd/create/create_quota.go @@ -0,0 +1,101 @@ +/* +Copyright 2016 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 create + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + quotaLong = templates.LongDesc(i18n.T(` + Create a resourcequota with the specified name, hard limits and optional scopes`)) + + quotaExample = templates.Examples(i18n.T(` + # Create a new resourcequota named my-quota + kubectl create quota my-quota --hard=cpu=1,memory=1G,pods=2,services=3,replicationcontrollers=2,resourcequotas=1,secrets=5,persistentvolumeclaims=10 + + # Create a new resourcequota named best-effort + kubectl create quota best-effort --hard=pods=100 --scopes=BestEffort`)) +) + +// QuotaOpts holds the command-line options for 'create quota' sub command +type QuotaOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateQuota is a macro command to create a new quota +func NewCmdCreateQuota(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &QuotaOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "quota NAME [--hard=key1=value1,key2=value2] [--scopes=Scope1,Scope2] [--dry-run=bool]", + DisableFlagsInUseLine: true, + Aliases: []string{"resourcequota"}, + Short: i18n.T("Create a quota with the specified name."), + Long: quotaLong, + Example: quotaExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.ResourceQuotaV1GeneratorName) + cmd.Flags().String("hard", "", i18n.T("A comma-delimited set of resource=quantity pairs that define a hard limit.")) + cmd.Flags().String("scopes", "", i18n.T("A comma-delimited set of quota scopes that must all match each object tracked by the quota.")) + return cmd +} + +// Complete completes all the required options +func (o *QuotaOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.ResourceQuotaV1GeneratorName: + generator = &generateversioned.ResourceQuotaGeneratorV1{ + Name: name, + Hard: cmdutil.GetFlagString(cmd, "hard"), + Scopes: cmdutil.GetFlagString(cmd, "scopes"), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in QuotaOpts instance +func (o *QuotaOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_quota_test.go b/pkg/cmd/create/create_quota_test.go new file mode 100644 index 000000000..2f48fd755 --- /dev/null +++ b/pkg/cmd/create/create_quota_test.go @@ -0,0 +1,68 @@ +/* +Copyright 2016 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 create + +import ( + "testing" + + "k8s.io/api/core/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreateQuota(t *testing.T) { + resourceQuotaObject := &v1.ResourceQuota{} + resourceQuotaObject.Name = "my-quota" + + tests := map[string]struct { + flags []string + expectedOutput string + }{ + "single resource": { + flags: []string{"--hard=cpu=1"}, + expectedOutput: "resourcequota/" + resourceQuotaObject.Name + "\n", + }, + "single resource with a scope": { + flags: []string{"--hard=cpu=1", "--scopes=BestEffort"}, + expectedOutput: "resourcequota/" + resourceQuotaObject.Name + "\n", + }, + "multiple resources": { + flags: []string{"--hard=cpu=1,pods=42", "--scopes=BestEffort"}, + expectedOutput: "resourcequota/" + resourceQuotaObject.Name + "\n", + }, + "single resource with multiple scopes": { + flags: []string{"--hard=cpu=1", "--scopes=BestEffort,NotTerminating"}, + expectedOutput: "resourcequota/" + resourceQuotaObject.Name + "\n", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateQuota(tf, ioStreams) + cmd.Flags().Parse(test.flags) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{resourceQuotaObject.Name}) + + if buf.String() != test.expectedOutput { + t.Errorf("%s: expected output: %s, but got: %s", name, test.expectedOutput, buf.String()) + } + }) + } +} diff --git a/pkg/cmd/create/create_role.go b/pkg/cmd/create/create_role.go new file mode 100644 index 000000000..7c5412ada --- /dev/null +++ b/pkg/cmd/create/create_role.go @@ -0,0 +1,402 @@ +/* +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 create + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/cli-runtime/pkg/genericclioptions" + clientgorbacv1 "k8s.io/client-go/kubernetes/typed/rbac/v1" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + roleLong = templates.LongDesc(i18n.T(` + Create a role with single rule.`)) + + roleExample = templates.Examples(i18n.T(` + # Create a Role named "pod-reader" that allows user to perform "get", "watch" and "list" on pods + kubectl create role pod-reader --verb=get --verb=list --verb=watch --resource=pods + + # Create a Role named "pod-reader" with ResourceName specified + kubectl create role pod-reader --verb=get --resource=pods --resource-name=readablepod --resource-name=anotherpod + + # Create a Role named "foo" with API Group specified + kubectl create role foo --verb=get,list,watch --resource=rs.extensions + + # Create a Role named "foo" with SubResource specified + kubectl create role foo --verb=get,list,watch --resource=pods,pods/status`)) + + // Valid resource verb list for validation. + validResourceVerbs = []string{"*", "get", "delete", "list", "create", "update", "patch", "watch", "proxy", "deletecollection", "use", "bind", "escalate", "impersonate"} + + // Specialized verbs and GroupResources + specialVerbs = map[string][]schema.GroupResource{ + "use": { + { + Group: "extensions", + Resource: "podsecuritypolicies", + }, + }, + "bind": { + { + Group: "rbac.authorization.k8s.io", + Resource: "roles", + }, + { + Group: "rbac.authorization.k8s.io", + Resource: "clusterroles", + }, + }, + "escalate": { + { + Group: "rbac.authorization.k8s.io", + Resource: "roles", + }, + { + Group: "rbac.authorization.k8s.io", + Resource: "clusterroles", + }, + }, + "impersonate": { + { + Group: "", + Resource: "users", + }, + { + Group: "", + Resource: "serviceaccounts", + }, + { + Group: "", + Resource: "groups", + }, + { + Group: "authentication.k8s.io", + Resource: "userextras", + }, + }, + } +) + +// ResourceOptions holds the related options for '--resource' option +type ResourceOptions struct { + Group string + Resource string + SubResource string +} + +// CreateRoleOptions holds the options for 'create role' sub command +type CreateRoleOptions struct { + PrintFlags *genericclioptions.PrintFlags + + Name string + Verbs []string + Resources []ResourceOptions + ResourceNames []string + + DryRun bool + OutputFormat string + Namespace string + Client clientgorbacv1.RbacV1Interface + Mapper meta.RESTMapper + PrintObj func(obj runtime.Object) error + + genericclioptions.IOStreams +} + +// NewCreateRoleOptions returns an initialized CreateRoleOptions instance +func NewCreateRoleOptions(ioStreams genericclioptions.IOStreams) *CreateRoleOptions { + return &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + + IOStreams: ioStreams, + } +} + +// NewCmdCreateRole returnns an initialized Command instance for 'create role' sub command +func NewCmdCreateRole(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewCreateRoleOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "role NAME --verb=verb --resource=resource.group/subresource [--resource-name=resourcename] [--dry-run]", + DisableFlagsInUseLine: true, + Short: roleLong, + Long: roleLong, + Example: roleExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunCreateRole()) + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddDryRunFlag(cmd) + cmd.Flags().StringSliceVar(&o.Verbs, "verb", o.Verbs, "Verb that applies to the resources contained in the rule") + cmd.Flags().StringSlice("resource", []string{}, "Resource that the rule applies to") + cmd.Flags().StringArrayVar(&o.ResourceNames, "resource-name", o.ResourceNames, "Resource in the white list that the rule applies to, repeat this flag for multiple items") + + return cmd +} + +// Complete completes all the required options +func (o *CreateRoleOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + o.Name = name + + // Remove duplicate verbs. + verbs := []string{} + for _, v := range o.Verbs { + // VerbAll respresents all kinds of verbs. + if v == "*" { + verbs = []string{"*"} + break + } + if !arrayContains(verbs, v) { + verbs = append(verbs, v) + } + } + o.Verbs = verbs + + // Support resource.group pattern. If no API Group specified, use "" as core API Group. + // e.g. --resource=pods,deployments.extensions + resources := cmdutil.GetFlagStringSlice(cmd, "resource") + for _, r := range resources { + sections := strings.SplitN(r, "/", 2) + + resource := &ResourceOptions{} + if len(sections) == 2 { + resource.SubResource = sections[1] + } + + parts := strings.SplitN(sections[0], ".", 2) + if len(parts) == 2 { + resource.Group = parts[1] + } + resource.Resource = parts[0] + + if resource.Resource == "*" && len(parts) == 1 && len(sections) == 1 { + o.Resources = []ResourceOptions{*resource} + break + } + + o.Resources = append(o.Resources, *resource) + } + + // Remove duplicate resource names. + resourceNames := []string{} + for _, n := range o.ResourceNames { + if !arrayContains(resourceNames, n) { + resourceNames = append(resourceNames, n) + } + } + o.ResourceNames = resourceNames + + // Complete other options for Run. + o.Mapper, err = f.ToRESTMapper() + if err != nil { + return err + } + + o.DryRun = cmdutil.GetDryRunFlag(cmd) + o.OutputFormat = cmdutil.GetFlagString(cmd, "output") + + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = func(obj runtime.Object) error { + return printer.PrintObj(obj, o.Out) + } + + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + clientset, err := f.KubernetesClientSet() + if err != nil { + return err + } + o.Client = clientset.RbacV1() + + return nil +} + +// Validate makes sure there is no discrepency in provided option values +func (o *CreateRoleOptions) Validate() error { + if o.Name == "" { + return fmt.Errorf("name must be specified") + } + + // validate verbs. + if len(o.Verbs) == 0 { + return fmt.Errorf("at least one verb must be specified") + } + + for _, v := range o.Verbs { + if !arrayContains(validResourceVerbs, v) { + return fmt.Errorf("invalid verb: '%s'", v) + } + } + + // validate resources. + if len(o.Resources) == 0 { + return fmt.Errorf("at least one resource must be specified") + } + + return o.validateResource() +} + +func (o *CreateRoleOptions) validateResource() error { + for _, r := range o.Resources { + if len(r.Resource) == 0 { + return fmt.Errorf("resource must be specified if apiGroup/subresource specified") + } + if r.Resource == "*" { + return nil + } + + resource := schema.GroupVersionResource{Resource: r.Resource, Group: r.Group} + groupVersionResource, err := o.Mapper.ResourceFor(schema.GroupVersionResource{Resource: r.Resource, Group: r.Group}) + if err == nil { + resource = groupVersionResource + } + + for _, v := range o.Verbs { + if groupResources, ok := specialVerbs[v]; ok { + match := false + for _, extra := range groupResources { + if resource.Resource == extra.Resource && resource.Group == extra.Group { + match = true + err = nil + break + } + } + if !match { + return fmt.Errorf("can not perform '%s' on '%s' in group '%s'", v, resource.Resource, resource.Group) + } + } + } + + if err != nil { + return err + } + } + return nil +} + +// RunCreateRole performs the execution of 'create role' sub command +func (o *CreateRoleOptions) RunCreateRole() error { + role := &rbacv1.Role{ + // this is ok because we know exactly how we want to be serialized + TypeMeta: metav1.TypeMeta{APIVersion: rbacv1.SchemeGroupVersion.String(), Kind: "Role"}, + } + role.Name = o.Name + rules, err := generateResourcePolicyRules(o.Mapper, o.Verbs, o.Resources, o.ResourceNames, []string{}) + if err != nil { + return err + } + role.Rules = rules + + // Create role. + if !o.DryRun { + role, err = o.Client.Roles(o.Namespace).Create(role) + if err != nil { + return err + } + } + + return o.PrintObj(role) +} + +func arrayContains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func generateResourcePolicyRules(mapper meta.RESTMapper, verbs []string, resources []ResourceOptions, resourceNames []string, nonResourceURLs []string) ([]rbacv1.PolicyRule, error) { + // groupResourceMapping is a apigroup-resource map. The key of this map is api group, while the value + // is a string array of resources under this api group. + // E.g. groupResourceMapping = {"extensions": ["replicasets", "deployments"], "batch":["jobs"]} + groupResourceMapping := map[string][]string{} + + // This loop does the following work: + // 1. Constructs groupResourceMapping based on input resources. + // 2. Prevents pointing to non-existent resources. + // 3. Transfers resource short name to long name. E.g. rs.extensions is transferred to replicasets.extensions + for _, r := range resources { + resource := schema.GroupVersionResource{Resource: r.Resource, Group: r.Group} + groupVersionResource, err := mapper.ResourceFor(schema.GroupVersionResource{Resource: r.Resource, Group: r.Group}) + if err == nil { + resource = groupVersionResource + } + + if len(r.SubResource) > 0 { + resource.Resource = resource.Resource + "/" + r.SubResource + } + if !arrayContains(groupResourceMapping[resource.Group], resource.Resource) { + groupResourceMapping[resource.Group] = append(groupResourceMapping[resource.Group], resource.Resource) + } + } + + // Create separate rule for each of the api group. + rules := []rbacv1.PolicyRule{} + for _, g := range sets.StringKeySet(groupResourceMapping).List() { + rule := rbacv1.PolicyRule{} + rule.Verbs = verbs + rule.Resources = groupResourceMapping[g] + rule.APIGroups = []string{g} + rule.ResourceNames = resourceNames + rules = append(rules, rule) + } + + if len(nonResourceURLs) > 0 { + rule := rbacv1.PolicyRule{} + rule.Verbs = verbs + rule.NonResourceURLs = nonResourceURLs + rules = append(rules, rule) + } + + return rules, nil +} diff --git a/pkg/cmd/create/create_role_test.go b/pkg/cmd/create/create_role_test.go new file mode 100644 index 000000000..2ba453a7e --- /dev/null +++ b/pkg/cmd/create/create_role_test.go @@ -0,0 +1,673 @@ +/* +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 create + +import ( + "reflect" + "testing" + + rbac "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/diff" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreateRole(t *testing.T) { + roleName := "my-role" + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{} + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + tests := map[string]struct { + verbs string + resources string + resourceNames string + expectedRole *rbac.Role + }{ + "test-duplicate-resources": { + verbs: "get,watch,list", + resources: "pods,pods", + expectedRole: &rbac.Role{ + TypeMeta: v1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"}, + ObjectMeta: v1.ObjectMeta{ + Name: roleName, + }, + Rules: []rbac.PolicyRule{ + { + Verbs: []string{"get", "watch", "list"}, + Resources: []string{"pods"}, + APIGroups: []string{""}, + ResourceNames: []string{}, + }, + }, + }, + }, + "test-subresources": { + verbs: "get,watch,list", + resources: "replicasets/scale", + expectedRole: &rbac.Role{ + TypeMeta: v1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"}, + ObjectMeta: v1.ObjectMeta{ + Name: roleName, + }, + Rules: []rbac.PolicyRule{ + { + Verbs: []string{"get", "watch", "list"}, + Resources: []string{"replicasets/scale"}, + APIGroups: []string{"extensions"}, + ResourceNames: []string{}, + }, + }, + }, + }, + "test-subresources-with-apigroup": { + verbs: "get,watch,list", + resources: "replicasets.extensions/scale", + expectedRole: &rbac.Role{ + TypeMeta: v1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"}, + ObjectMeta: v1.ObjectMeta{ + Name: roleName, + }, + Rules: []rbac.PolicyRule{ + { + Verbs: []string{"get", "watch", "list"}, + Resources: []string{"replicasets/scale"}, + APIGroups: []string{"extensions"}, + ResourceNames: []string{}, + }, + }, + }, + }, + "test-valid-case-with-multiple-apigroups": { + verbs: "get,watch,list", + resources: "pods,deployments.extensions", + expectedRole: &rbac.Role{ + TypeMeta: v1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"}, + ObjectMeta: v1.ObjectMeta{ + Name: roleName, + }, + Rules: []rbac.PolicyRule{ + { + Verbs: []string{"get", "watch", "list"}, + Resources: []string{"pods"}, + APIGroups: []string{""}, + ResourceNames: []string{}, + }, + { + Verbs: []string{"get", "watch", "list"}, + Resources: []string{"deployments"}, + APIGroups: []string{"extensions"}, + ResourceNames: []string{}, + }, + }, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateRole(tf, ioStreams) + cmd.Flags().Set("dry-run", "true") + cmd.Flags().Set("output", "yaml") + cmd.Flags().Set("verb", test.verbs) + cmd.Flags().Set("resource", test.resources) + if test.resourceNames != "" { + cmd.Flags().Set("resource-name", test.resourceNames) + } + cmd.Run(cmd, []string{roleName}) + actual := &rbac.Role{} + if err := runtime.DecodeInto(scheme.Codecs.UniversalDecoder(), buf.Bytes(), actual); err != nil { + t.Log(string(buf.Bytes())) + t.Fatal(err) + } + if !equality.Semantic.DeepEqual(test.expectedRole, actual) { + t.Errorf("%s", diff.ObjectReflectDiff(test.expectedRole, actual)) + } + }) + } +} + +func TestValidate(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tests := map[string]struct { + roleOptions *CreateRoleOptions + expectErr bool + }{ + "test-missing-name": { + roleOptions: &CreateRoleOptions{}, + expectErr: true, + }, + "test-missing-verb": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + }, + expectErr: true, + }, + "test-missing-resource": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"get"}, + }, + expectErr: true, + }, + "test-missing-resource-existing-apigroup": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"get"}, + Resources: []ResourceOptions{ + { + Group: "extensions", + }, + }, + }, + expectErr: true, + }, + "test-missing-resource-existing-subresource": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"get"}, + Resources: []ResourceOptions{ + { + SubResource: "scale", + }, + }, + }, + expectErr: true, + }, + "test-invalid-verb": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"invalid-verb"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + }, + }, + }, + expectErr: true, + }, + "test-nonresource-verb": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"post"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + }, + }, + }, + expectErr: true, + }, + "test-special-verb": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"use"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + }, + }, + }, + expectErr: true, + }, + "test-mix-verbs": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"impersonate", "use"}, + Resources: []ResourceOptions{ + { + Resource: "userextras", + SubResource: "scopes", + }, + }, + }, + expectErr: true, + }, + "test-special-verb-with-wrong-apigroup": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"impersonate"}, + Resources: []ResourceOptions{ + { + Resource: "userextras", + SubResource: "scopes", + Group: "extensions", + }, + }, + }, + expectErr: true, + }, + "test-invalid-resource": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"get"}, + Resources: []ResourceOptions{ + { + Resource: "invalid-resource", + }, + }, + }, + expectErr: true, + }, + "test-resource-name-with-multiple-resources": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"get"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + }, + { + Resource: "deployments", + Group: "extensions", + }, + }, + ResourceNames: []string{"foo"}, + }, + expectErr: false, + }, + "test-valid-case": { + roleOptions: &CreateRoleOptions{ + Name: "role-binder", + Verbs: []string{"get", "list", "bind"}, + Resources: []ResourceOptions{ + { + Resource: "roles", + Group: "rbac.authorization.k8s.io", + }, + }, + ResourceNames: []string{"foo"}, + }, + expectErr: false, + }, + "test-valid-case-with-subresource": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"get", "list"}, + Resources: []ResourceOptions{ + { + Resource: "replicasets", + SubResource: "scale", + }, + }, + ResourceNames: []string{"bar"}, + }, + expectErr: false, + }, + "test-valid-case-with-additional-resource": { + roleOptions: &CreateRoleOptions{ + Name: "my-role", + Verbs: []string{"impersonate"}, + Resources: []ResourceOptions{ + { + Resource: "userextras", + SubResource: "scopes", + Group: "authentication.k8s.io", + }, + }, + }, + expectErr: false, + }, + } + + for name, test := range tests { + var err error + test.roleOptions.Mapper, err = tf.ToRESTMapper() + if err != nil { + t.Fatal(err) + } + err = test.roleOptions.Validate() + if test.expectErr && err == nil { + t.Errorf("%s: expect error happens but validate passes.", name) + } + if !test.expectErr && err != nil { + t.Errorf("%s: unexpected error: %v", name, err) + } + } +} + +func TestComplete(t *testing.T) { + roleName := "my-role" + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{} + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + defaultTestResources := "pods,deployments.extensions" + + tests := map[string]struct { + params []string + resources string + roleOptions *CreateRoleOptions + expected *CreateRoleOptions + expectErr bool + }{ + "test-missing-name": { + params: []string{}, + resources: defaultTestResources, + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + }, + expectErr: true, + }, + "test-duplicate-verbs": { + params: []string{roleName}, + resources: defaultTestResources, + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + Name: roleName, + Verbs: []string{ + "get", + "watch", + "list", + "get", + }, + }, + expected: &CreateRoleOptions{ + Name: roleName, + Verbs: []string{ + "get", + "watch", + "list", + }, + Resources: []ResourceOptions{ + { + Resource: "pods", + Group: "", + }, + { + Resource: "deployments", + Group: "extensions", + }, + }, + ResourceNames: []string{}, + }, + expectErr: false, + }, + "test-verball": { + params: []string{roleName}, + resources: defaultTestResources, + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + Name: roleName, + Verbs: []string{ + "get", + "watch", + "list", + "*", + }, + }, + expected: &CreateRoleOptions{ + Name: roleName, + Verbs: []string{"*"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + Group: "", + }, + { + Resource: "deployments", + Group: "extensions", + }, + }, + ResourceNames: []string{}, + }, + expectErr: false, + }, + "test-allresource": { + params: []string{roleName}, + resources: "*,pods", + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created"), + Name: roleName, + Verbs: []string{"*"}, + }, + expected: &CreateRoleOptions{ + Name: roleName, + Verbs: []string{"*"}, + Resources: []ResourceOptions{ + { + Resource: "*", + }, + }, + ResourceNames: []string{}, + }, + expectErr: false, + }, + "test-allresource-subresource": { + params: []string{roleName}, + resources: "*/scale,pods", + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created"), + Name: roleName, + Verbs: []string{"*"}, + }, + expected: &CreateRoleOptions{ + Name: roleName, + Verbs: []string{"*"}, + Resources: []ResourceOptions{ + { + Resource: "*", + SubResource: "scale", + }, + { + Resource: "pods", + }, + }, + ResourceNames: []string{}, + }, + expectErr: false, + }, + "test-allresrouce-allgroup": { + params: []string{roleName}, + resources: "*.*,pods", + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created"), + Name: roleName, + Verbs: []string{"*"}, + }, + expected: &CreateRoleOptions{ + Name: roleName, + Verbs: []string{"*"}, + Resources: []ResourceOptions{ + { + Resource: "*", + Group: "*", + }, + { + Resource: "pods", + }, + }, + ResourceNames: []string{}, + }, + expectErr: false, + }, + "test-allresource-allgroup-subresource": { + params: []string{roleName}, + resources: "*.*/scale,pods", + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created"), + Name: roleName, + Verbs: []string{"*"}, + }, + expected: &CreateRoleOptions{ + Name: roleName, + Verbs: []string{"*"}, + Resources: []ResourceOptions{ + { + Resource: "*", + Group: "*", + SubResource: "scale", + }, + { + Resource: "pods", + }, + }, + ResourceNames: []string{}, + }, + expectErr: false, + }, + "test-allresource-specificgroup": { + params: []string{roleName}, + resources: "*.extensions,pods", + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created"), + Name: roleName, + Verbs: []string{"*"}, + }, + expected: &CreateRoleOptions{ + Name: roleName, + Verbs: []string{"*"}, + Resources: []ResourceOptions{ + { + Resource: "*", + Group: "extensions", + }, + { + Resource: "pods", + }, + }, + ResourceNames: []string{}, + }, + expectErr: false, + }, + "test-allresource-specificgroup-subresource": { + params: []string{roleName}, + resources: "*.extensions/scale,pods", + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created"), + Name: roleName, + Verbs: []string{"*"}, + }, + expected: &CreateRoleOptions{ + Name: roleName, + Verbs: []string{"*"}, + Resources: []ResourceOptions{ + { + Resource: "*", + Group: "extensions", + SubResource: "scale", + }, + { + Resource: "pods", + }, + }, + ResourceNames: []string{}, + }, + expectErr: false, + }, + "test-duplicate-resourcenames": { + params: []string{roleName}, + resources: defaultTestResources, + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + Name: roleName, + Verbs: []string{"*"}, + ResourceNames: []string{"foo", "foo"}, + }, + expected: &CreateRoleOptions{ + Name: roleName, + Verbs: []string{"*"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + Group: "", + }, + { + Resource: "deployments", + Group: "extensions", + }, + }, + ResourceNames: []string{"foo"}, + }, + expectErr: false, + }, + "test-valid-complete-case": { + params: []string{roleName}, + resources: defaultTestResources, + roleOptions: &CreateRoleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + Name: roleName, + Verbs: []string{"*"}, + ResourceNames: []string{"foo"}, + }, + expected: &CreateRoleOptions{ + Name: roleName, + Verbs: []string{"*"}, + Resources: []ResourceOptions{ + { + Resource: "pods", + Group: "", + }, + { + Resource: "deployments", + Group: "extensions", + }, + }, + ResourceNames: []string{"foo"}, + }, + expectErr: false, + }, + } + + for name, test := range tests { + cmd := NewCmdCreateRole(tf, genericclioptions.NewTestIOStreamsDiscard()) + cmd.Flags().Set("resource", test.resources) + + err := test.roleOptions.Complete(tf, cmd, test.params) + if !test.expectErr && err != nil { + t.Errorf("%s: unexpected error: %v", name, err) + } + + if test.expectErr { + if err != nil { + continue + } else { + t.Errorf("%s: expect error happens but test passes.", name) + } + } + + if test.roleOptions.Name != test.expected.Name { + t.Errorf("%s:\nexpected name:\n%#v\nsaw name:\n%#v", name, test.expected.Name, test.roleOptions.Name) + } + + if !reflect.DeepEqual(test.roleOptions.Verbs, test.expected.Verbs) { + t.Errorf("%s:\nexpected verbs:\n%#v\nsaw verbs:\n%#v", name, test.expected.Verbs, test.roleOptions.Verbs) + } + + if !reflect.DeepEqual(test.roleOptions.Resources, test.expected.Resources) { + t.Errorf("%s:\nexpected resources:\n%#v\nsaw resources:\n%#v", name, test.expected.Resources, test.roleOptions.Resources) + } + + if !reflect.DeepEqual(test.roleOptions.ResourceNames, test.expected.ResourceNames) { + t.Errorf("%s:\nexpected resource names:\n%#v\nsaw resource names:\n%#v", name, test.expected.ResourceNames, test.roleOptions.ResourceNames) + } + } +} diff --git a/pkg/cmd/create/create_rolebinding.go b/pkg/cmd/create/create_rolebinding.go new file mode 100644 index 000000000..cae2b2c81 --- /dev/null +++ b/pkg/cmd/create/create_rolebinding.go @@ -0,0 +1,103 @@ +/* +Copyright 2016 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 create + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + roleBindingLong = templates.LongDesc(i18n.T(` + Create a RoleBinding for a particular Role or ClusterRole.`)) + + roleBindingExample = templates.Examples(i18n.T(` + # Create a RoleBinding for user1, user2, and group1 using the admin ClusterRole + kubectl create rolebinding admin --clusterrole=admin --user=user1 --user=user2 --group=group1`)) +) + +// RoleBindingOpts holds the options for 'create rolebinding' sub command +type RoleBindingOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateRoleBinding returns an initialized Command instance for 'create rolebinding' sub command +func NewCmdCreateRoleBinding(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := &RoleBindingOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "rolebinding NAME --clusterrole=NAME|--role=NAME [--user=username] [--group=groupname] [--serviceaccount=namespace:serviceaccountname] [--dry-run]", + DisableFlagsInUseLine: true, + Short: i18n.T("Create a RoleBinding for a particular Role or ClusterRole"), + Long: roleBindingLong, + Example: roleBindingExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Run()) + }, + } + + o.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.RoleBindingV1GeneratorName) + cmd.Flags().String("clusterrole", "", i18n.T("ClusterRole this RoleBinding should reference")) + cmd.Flags().String("role", "", i18n.T("Role this RoleBinding should reference")) + cmd.Flags().StringArray("user", []string{}, "Usernames to bind to the role") + cmd.Flags().StringArray("group", []string{}, "Groups to bind to the role") + cmd.Flags().StringArray("serviceaccount", []string{}, "Service accounts to bind to the role, in the format :") + return cmd +} + +// Complete completes all the required options +func (o *RoleBindingOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.RoleBindingV1GeneratorName: + generator = &generateversioned.RoleBindingGeneratorV1{ + Name: name, + ClusterRole: cmdutil.GetFlagString(cmd, "clusterrole"), + Role: cmdutil.GetFlagString(cmd, "role"), + Users: cmdutil.GetFlagStringArray(cmd, "user"), + Groups: cmdutil.GetFlagStringArray(cmd, "group"), + ServiceAccounts: cmdutil.GetFlagStringArray(cmd, "serviceaccount"), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in RoleBindingOpts instance +func (o *RoleBindingOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_rolebinding_test.go b/pkg/cmd/create/create_rolebinding_test.go new file mode 100644 index 000000000..620cc6d3e --- /dev/null +++ b/pkg/cmd/create/create_rolebinding_test.go @@ -0,0 +1,144 @@ +/* +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 create + +import ( + "bytes" + "io/ioutil" + "net/http" + "net/url" + "reflect" + "testing" + + rbac "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +var groupVersion = schema.GroupVersion{Group: "rbac.authorization.k8s.io", Version: "v1"} + +func TestCreateRoleBinding(t *testing.T) { + expectBinding := &rbac.RoleBinding{ + TypeMeta: v1.TypeMeta{ + APIVersion: "rbac.authorization.k8s.io/v1", + Kind: "RoleBinding", + }, + ObjectMeta: v1.ObjectMeta{ + Name: "fake-binding", + }, + RoleRef: rbac.RoleRef{ + APIGroup: rbac.GroupName, + Kind: "Role", + Name: "fake-role", + }, + Subjects: []rbac.Subject{ + { + Kind: rbac.UserKind, + APIGroup: "rbac.authorization.k8s.io", + Name: "fake-user", + }, + { + Kind: rbac.GroupKind, + APIGroup: "rbac.authorization.k8s.io", + Name: "fake-group", + }, + { + Kind: rbac.ServiceAccountKind, + Namespace: "fake-namespace", + Name: "fake-account", + }, + }, + } + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + ns := scheme.Codecs + + info, _ := runtime.SerializerInfoForMediaType(ns.SupportedMediaTypes(), runtime.ContentTypeJSON) + encoder := ns.EncoderForVersion(info.Serializer, groupVersion) + decoder := ns.DecoderToVersion(info.Serializer, groupVersion) + + tf.Client = &RoleBindingRESTClient{ + RESTClient: &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/rolebindings" && m == "POST": + bodyBits, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatalf("TestCreateRoleBinding error: %v", err) + return nil, nil + } + + if obj, _, err := decoder.Decode(bodyBits, nil, &rbac.RoleBinding{}); err == nil { + if !reflect.DeepEqual(obj.(*rbac.RoleBinding), expectBinding) { + t.Fatalf("TestCreateRoleBinding: expected:\n%#v\nsaw:\n%#v", expectBinding, obj.(*rbac.RoleBinding)) + return nil, nil + } + } else { + t.Fatalf("TestCreateRoleBinding error, could not decode the request body into rbac.RoleBinding object: %v", err) + return nil, nil + } + + responseBinding := &rbac.RoleBinding{} + responseBinding.Name = "fake-binding" + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(encoder, responseBinding))))}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + }, + } + + cmd := NewCmdCreateRoleBinding(tf, genericclioptions.NewTestIOStreamsDiscard()) + cmd.Flags().Set("role", "fake-role") + cmd.Flags().Set("user", "fake-user") + cmd.Flags().Set("group", "fake-group") + cmd.Flags().Set("serviceaccount", "fake-namespace:fake-account") + cmd.Run(cmd, []string{"fake-binding"}) +} + +type RoleBindingRESTClient struct { + *fake.RESTClient +} + +func (c *RoleBindingRESTClient) Post() *restclient.Request { + config := restclient.ContentConfig{ + ContentType: runtime.ContentTypeJSON, + GroupVersion: &groupVersion, + NegotiatedSerializer: c.NegotiatedSerializer, + } + + info, _ := runtime.SerializerInfoForMediaType(c.NegotiatedSerializer.SupportedMediaTypes(), runtime.ContentTypeJSON) + serializers := restclient.Serializers{ + Encoder: c.NegotiatedSerializer.EncoderForVersion(info.Serializer, groupVersion), + Decoder: c.NegotiatedSerializer.DecoderToVersion(info.Serializer, groupVersion), + } + if info.StreamSerializer != nil { + serializers.StreamingSerializer = info.StreamSerializer.Serializer + serializers.Framer = info.StreamSerializer.Framer + } + return restclient.NewRequest(c, "POST", &url.URL{Host: "localhost"}, c.VersionedAPIPath, config, serializers, nil, nil, 0) +} diff --git a/pkg/cmd/create/create_secret.go b/pkg/cmd/create/create_secret.go new file mode 100644 index 000000000..102a6310c --- /dev/null +++ b/pkg/cmd/create/create_secret.go @@ -0,0 +1,322 @@ +/* +Copyright 2015 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 create + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// NewCmdCreateSecret groups subcommands to create various types of secrets +func NewCmdCreateSecret(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "secret", + Short: i18n.T("Create a secret using specified subcommand"), + Long: "Create a secret using specified subcommand.", + Run: cmdutil.DefaultSubCommandRun(ioStreams.ErrOut), + } + cmd.AddCommand(NewCmdCreateSecretDockerRegistry(f, ioStreams)) + cmd.AddCommand(NewCmdCreateSecretTLS(f, ioStreams)) + cmd.AddCommand(NewCmdCreateSecretGeneric(f, ioStreams)) + + return cmd +} + +var ( + secretLong = templates.LongDesc(i18n.T(` + Create a secret based on a file, directory, or specified literal value. + + A single secret may package one or more key/value pairs. + + When creating a secret based on a file, the key will default to the basename of the file, and the value will + default to the file content. If the basename is an invalid key or you wish to chose your own, you may specify + an alternate key. + + When creating a secret based on a directory, each file whose basename is a valid key in the directory will be + packaged into the secret. Any directory entries except regular files are ignored (e.g. subdirectories, + symlinks, devices, pipes, etc).`)) + + secretExample = templates.Examples(i18n.T(` + # Create a new secret named my-secret with keys for each file in folder bar + kubectl create secret generic my-secret --from-file=path/to/bar + + # Create a new secret named my-secret with specified keys instead of names on disk + kubectl create secret generic my-secret --from-file=ssh-privatekey=path/to/id_rsa --from-file=ssh-publickey=path/to/id_rsa.pub + + # Create a new secret named my-secret with key1=supersecret and key2=topsecret + kubectl create secret generic my-secret --from-literal=key1=supersecret --from-literal=key2=topsecret + + # Create a new secret named my-secret using a combination of a file and a literal + kubectl create secret generic my-secret --from-file=ssh-privatekey=path/to/id_rsa --from-literal=passphrase=topsecret + + # Create a new secret named my-secret from an env file + kubectl create secret generic my-secret --from-env-file=path/to/bar.env`)) +) + +// SecretGenericOpts holds the options for 'create secret' sub command +type SecretGenericOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateSecretGeneric is a command to create generic secrets from files, directories, or literal values +func NewCmdCreateSecretGeneric(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &SecretGenericOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "generic NAME [--type=string] [--from-file=[key=]source] [--from-literal=key1=value1] [--dry-run]", + DisableFlagsInUseLine: true, + Short: i18n.T("Create a secret from a local file, directory or literal value"), + Long: secretLong, + Example: secretExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.SecretV1GeneratorName) + cmd.Flags().StringSlice("from-file", []string{}, "Key files can be specified using their file path, in which case a default name will be given to them, or optionally with a name and file path, in which case the given name will be used. Specifying a directory will iterate each named file in the directory that is a valid secret key.") + cmd.Flags().StringArray("from-literal", []string{}, "Specify a key and literal value to insert in secret (i.e. mykey=somevalue)") + cmd.Flags().String("from-env-file", "", "Specify the path to a file to read lines of key=val pairs to create a secret (i.e. a Docker .env file).") + cmd.Flags().String("type", "", i18n.T("The type of secret to create")) + cmd.Flags().Bool("append-hash", false, "Append a hash of the secret to its name.") + return cmd +} + +// Complete completes all the required options +func (o *SecretGenericOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.SecretV1GeneratorName: + generator = &generateversioned.SecretGeneratorV1{ + Name: name, + Type: cmdutil.GetFlagString(cmd, "type"), + FileSources: cmdutil.GetFlagStringSlice(cmd, "from-file"), + LiteralSources: cmdutil.GetFlagStringArray(cmd, "from-literal"), + EnvFileSource: cmdutil.GetFlagString(cmd, "from-env-file"), + AppendHash: cmdutil.GetFlagBool(cmd, "append-hash"), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in SecretGenericOpts instance +func (o *SecretGenericOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} + +var ( + secretForDockerRegistryLong = templates.LongDesc(i18n.T(` + Create a new secret for use with Docker registries. + + Dockercfg secrets are used to authenticate against Docker registries. + + When using the Docker command line to push images, you can authenticate to a given registry by running: + '$ docker login DOCKER_REGISTRY_SERVER --username=DOCKER_USER --password=DOCKER_PASSWORD --email=DOCKER_EMAIL'. + + That produces a ~/.dockercfg file that is used by subsequent 'docker push' and 'docker pull' commands to + authenticate to the registry. The email address is optional. + + When creating applications, you may have a Docker registry that requires authentication. In order for the + nodes to pull images on your behalf, they have to have the credentials. You can provide this information + by creating a dockercfg secret and attaching it to your service account.`)) + + secretForDockerRegistryExample = templates.Examples(i18n.T(` + # If you don't already have a .dockercfg file, you can create a dockercfg secret directly by using: + kubectl create secret docker-registry my-secret --docker-server=DOCKER_REGISTRY_SERVER --docker-username=DOCKER_USER --docker-password=DOCKER_PASSWORD --docker-email=DOCKER_EMAIL`)) +) + +// SecretDockerRegistryOpts holds the options for 'create secret docker-registry' sub command +type SecretDockerRegistryOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateSecretDockerRegistry is a macro command for creating secrets to work with Docker registries +func NewCmdCreateSecretDockerRegistry(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &SecretDockerRegistryOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "docker-registry NAME --docker-username=user --docker-password=password --docker-email=email [--docker-server=string] [--from-literal=key1=value1] [--dry-run]", + DisableFlagsInUseLine: true, + Short: i18n.T("Create a secret for use with a Docker registry"), + Long: secretForDockerRegistryLong, + Example: secretForDockerRegistryExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.SecretForDockerRegistryV1GeneratorName) + cmd.Flags().String("docker-username", "", i18n.T("Username for Docker registry authentication")) + cmd.MarkFlagRequired("docker-username") + cmd.Flags().String("docker-password", "", i18n.T("Password for Docker registry authentication")) + cmd.MarkFlagRequired("docker-password") + cmd.Flags().String("docker-email", "", i18n.T("Email for Docker registry")) + cmd.Flags().String("docker-server", "https://index.docker.io/v1/", i18n.T("Server location for Docker registry")) + cmd.Flags().Bool("append-hash", false, "Append a hash of the secret to its name.") + cmd.Flags().StringSlice("from-file", []string{}, "Key files can be specified using their file path, in which case a default name will be given to them, or optionally with a name and file path, in which case the given name will be used. Specifying a directory will iterate each named file in the directory that is a valid secret key.") + + return cmd +} + +// Complete completes all the required options +func (o *SecretDockerRegistryOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + fromFileFlag := cmdutil.GetFlagStringSlice(cmd, "from-file") + if len(fromFileFlag) == 0 { + requiredFlags := []string{"docker-username", "docker-password", "docker-server"} + for _, requiredFlag := range requiredFlags { + if value := cmdutil.GetFlagString(cmd, requiredFlag); len(value) == 0 { + return cmdutil.UsageErrorf(cmd, "flag %s is required", requiredFlag) + } + } + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.SecretForDockerRegistryV1GeneratorName: + generator = &generateversioned.SecretForDockerRegistryGeneratorV1{ + Name: name, + Username: cmdutil.GetFlagString(cmd, "docker-username"), + Email: cmdutil.GetFlagString(cmd, "docker-email"), + Password: cmdutil.GetFlagString(cmd, "docker-password"), + Server: cmdutil.GetFlagString(cmd, "docker-server"), + AppendHash: cmdutil.GetFlagBool(cmd, "append-hash"), + FileSources: cmdutil.GetFlagStringSlice(cmd, "from-file"), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls CreateSubcommandOptions.Run in SecretDockerRegistryOpts instance +func (o *SecretDockerRegistryOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} + +var ( + secretForTLSLong = templates.LongDesc(i18n.T(` + Create a TLS secret from the given public/private key pair. + + The public/private key pair must exist before hand. The public key certificate must be .PEM encoded and match + the given private key.`)) + + secretForTLSExample = templates.Examples(i18n.T(` + # Create a new TLS secret named tls-secret with the given key pair: + kubectl create secret tls tls-secret --cert=path/to/tls.cert --key=path/to/tls.key`)) +) + +// SecretTLSOpts holds the options for 'create secret tls' sub command +type SecretTLSOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateSecretTLS is a macro command for creating secrets to work with Docker registries +func NewCmdCreateSecretTLS(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &SecretTLSOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "tls NAME --cert=path/to/cert/file --key=path/to/key/file [--dry-run]", + DisableFlagsInUseLine: true, + Short: i18n.T("Create a TLS secret"), + Long: secretForTLSLong, + Example: secretForTLSExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.SecretForTLSV1GeneratorName) + cmd.Flags().String("cert", "", i18n.T("Path to PEM encoded public key certificate.")) + cmd.Flags().String("key", "", i18n.T("Path to private key associated with given certificate.")) + cmd.Flags().Bool("append-hash", false, "Append a hash of the secret to its name.") + return cmd +} + +// Complete completes all the required options +func (o *SecretTLSOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + requiredFlags := []string{"cert", "key"} + for _, requiredFlag := range requiredFlags { + if value := cmdutil.GetFlagString(cmd, requiredFlag); len(value) == 0 { + return cmdutil.UsageErrorf(cmd, "flag %s is required", requiredFlag) + } + } + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.SecretForTLSV1GeneratorName: + generator = &generateversioned.SecretForTLSGeneratorV1{ + Name: name, + Key: cmdutil.GetFlagString(cmd, "key"), + Cert: cmdutil.GetFlagString(cmd, "cert"), + AppendHash: cmdutil.GetFlagBool(cmd, "append-hash"), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls CreateSubcommandOptions.Run in the SecretTLSOpts instance +func (o *SecretTLSOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_secret_test.go b/pkg/cmd/create/create_secret_test.go new file mode 100644 index 000000000..7ed86bc85 --- /dev/null +++ b/pkg/cmd/create/create_secret_test.go @@ -0,0 +1,101 @@ +/* +Copyright 2014 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 create + +import ( + "net/http" + "testing" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreateSecretGeneric(t *testing.T) { + secretObject := &v1.Secret{ + Data: map[string][]byte{ + "password": []byte("includes,comma"), + "username": []byte("test_user"), + }, + } + secretObject.Name = "my-secret" + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/secrets" && m == "POST": + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, secretObject)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateSecretGeneric(tf, ioStreams) + cmd.Flags().Set("output", "name") + cmd.Flags().Set("from-literal", "password=includes,comma") + cmd.Flags().Set("from-literal", "username=test_user") + cmd.Run(cmd, []string{secretObject.Name}) + expectedOutput := "secret/" + secretObject.Name + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} + +func TestCreateSecretDockerRegistry(t *testing.T) { + secretObject := &v1.Secret{} + secretObject.Name = "my-secret" + tf := cmdtesting.NewTestFactory().WithNamespace("test") + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/secrets" && m == "POST": + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, secretObject)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateSecretDockerRegistry(tf, ioStreams) + cmd.Flags().Set("docker-username", "test-user") + cmd.Flags().Set("docker-password", "test-pass") + cmd.Flags().Set("docker-email", "test-email") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{secretObject.Name}) + expectedOutput := "secret/" + secretObject.Name + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", buf.String(), expectedOutput) + } +} diff --git a/pkg/cmd/create/create_service.go b/pkg/cmd/create/create_service.go new file mode 100644 index 000000000..c1267e00e --- /dev/null +++ b/pkg/cmd/create/create_service.go @@ -0,0 +1,342 @@ +/* +Copyright 2016 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 create + +import ( + "github.com/spf13/cobra" + + "k8s.io/api/core/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// NewCmdCreateService is a macro command to create a new service +func NewCmdCreateService(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "service", + Aliases: []string{"svc"}, + Short: i18n.T("Create a service using specified subcommand."), + Long: "Create a service using specified subcommand.", + Run: cmdutil.DefaultSubCommandRun(ioStreams.ErrOut), + } + cmd.AddCommand(NewCmdCreateServiceClusterIP(f, ioStreams)) + cmd.AddCommand(NewCmdCreateServiceNodePort(f, ioStreams)) + cmd.AddCommand(NewCmdCreateServiceLoadBalancer(f, ioStreams)) + cmd.AddCommand(NewCmdCreateServiceExternalName(f, ioStreams)) + + return cmd +} + +var ( + serviceClusterIPLong = templates.LongDesc(i18n.T(` + Create a ClusterIP service with the specified name.`)) + + serviceClusterIPExample = templates.Examples(i18n.T(` + # Create a new ClusterIP service named my-cs + kubectl create service clusterip my-cs --tcp=5678:8080 + + # Create a new ClusterIP service named my-cs (in headless mode) + kubectl create service clusterip my-cs --clusterip="None"`)) +) + +func addPortFlags(cmd *cobra.Command) { + cmd.Flags().StringSlice("tcp", []string{}, "Port pairs can be specified as ':'.") +} + +// ServiceClusterIPOpts holds the options for 'create service clusterip' sub command +type ServiceClusterIPOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateServiceClusterIP is a command to create a ClusterIP service +func NewCmdCreateServiceClusterIP(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &ServiceClusterIPOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "clusterip NAME [--tcp=:] [--dry-run]", + DisableFlagsInUseLine: true, + Short: i18n.T("Create a ClusterIP service."), + Long: serviceClusterIPLong, + Example: serviceClusterIPExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.ServiceClusterIPGeneratorV1Name) + addPortFlags(cmd) + cmd.Flags().String("clusterip", "", i18n.T("Assign your own ClusterIP or set to 'None' for a 'headless' service (no loadbalancing).")) + return cmd +} + +func errUnsupportedGenerator(cmd *cobra.Command, generatorName string) error { + return cmdutil.UsageErrorf(cmd, "Generator %s not supported. ", generatorName) +} + +// Complete completes all the required options +func (o *ServiceClusterIPOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.ServiceClusterIPGeneratorV1Name: + generator = &generateversioned.ServiceCommonGeneratorV1{ + Name: name, + TCP: cmdutil.GetFlagStringSlice(cmd, "tcp"), + Type: v1.ServiceTypeClusterIP, + ClusterIP: cmdutil.GetFlagString(cmd, "clusterip"), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in ServiceClusterIPOpts instance +func (o *ServiceClusterIPOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} + +var ( + serviceNodePortLong = templates.LongDesc(i18n.T(` + Create a NodePort service with the specified name.`)) + + serviceNodePortExample = templates.Examples(i18n.T(` + # Create a new NodePort service named my-ns + kubectl create service nodeport my-ns --tcp=5678:8080`)) +) + +// ServiceNodePortOpts holds the options for 'create service nodeport' sub command +type ServiceNodePortOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateServiceNodePort is a macro command for creating a NodePort service +func NewCmdCreateServiceNodePort(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &ServiceNodePortOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "nodeport NAME [--tcp=port:targetPort] [--dry-run]", + DisableFlagsInUseLine: true, + Short: i18n.T("Create a NodePort service."), + Long: serviceNodePortLong, + Example: serviceNodePortExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.ServiceNodePortGeneratorV1Name) + cmd.Flags().Int("node-port", 0, "Port used to expose the service on each node in a cluster.") + addPortFlags(cmd) + return cmd +} + +// Complete completes all the required options +func (o *ServiceNodePortOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.ServiceNodePortGeneratorV1Name: + generator = &generateversioned.ServiceCommonGeneratorV1{ + Name: name, + TCP: cmdutil.GetFlagStringSlice(cmd, "tcp"), + Type: v1.ServiceTypeNodePort, + ClusterIP: "", + NodePort: cmdutil.GetFlagInt(cmd, "node-port"), + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in ServiceNodePortOpts instance +func (o *ServiceNodePortOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} + +var ( + serviceLoadBalancerLong = templates.LongDesc(i18n.T(` + Create a LoadBalancer service with the specified name.`)) + + serviceLoadBalancerExample = templates.Examples(i18n.T(` + # Create a new LoadBalancer service named my-lbs + kubectl create service loadbalancer my-lbs --tcp=5678:8080`)) +) + +// ServiceLoadBalancerOpts holds the options for 'create service loadbalancer' sub command +type ServiceLoadBalancerOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateServiceLoadBalancer is a macro command for creating a LoadBalancer service +func NewCmdCreateServiceLoadBalancer(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &ServiceLoadBalancerOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "loadbalancer NAME [--tcp=port:targetPort] [--dry-run]", + DisableFlagsInUseLine: true, + Short: i18n.T("Create a LoadBalancer service."), + Long: serviceLoadBalancerLong, + Example: serviceLoadBalancerExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.ServiceLoadBalancerGeneratorV1Name) + addPortFlags(cmd) + return cmd +} + +// Complete completes all the required options +func (o *ServiceLoadBalancerOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.ServiceLoadBalancerGeneratorV1Name: + generator = &generateversioned.ServiceCommonGeneratorV1{ + Name: name, + TCP: cmdutil.GetFlagStringSlice(cmd, "tcp"), + Type: v1.ServiceTypeLoadBalancer, + ClusterIP: "", + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in ServiceLoadBalancerOpts instance +func (o *ServiceLoadBalancerOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} + +var ( + serviceExternalNameLong = templates.LongDesc(i18n.T(` + Create an ExternalName service with the specified name. + + ExternalName service references to an external DNS address instead of + only pods, which will allow application authors to reference services + that exist off platform, on other clusters, or locally.`)) + + serviceExternalNameExample = templates.Examples(i18n.T(` + # Create a new ExternalName service named my-ns + kubectl create service externalname my-ns --external-name bar.com`)) +) + +// ServiceExternalNameOpts holds the options for 'create service externalname' sub command +type ServiceExternalNameOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateServiceExternalName is a macro command for creating an ExternalName service +func NewCmdCreateServiceExternalName(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &ServiceExternalNameOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "externalname NAME --external-name external.name [--dry-run]", + DisableFlagsInUseLine: true, + Short: i18n.T("Create an ExternalName service."), + Long: serviceExternalNameLong, + Example: serviceExternalNameExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.ServiceExternalNameGeneratorV1Name) + addPortFlags(cmd) + cmd.Flags().String("external-name", "", i18n.T("External name of service")) + cmd.MarkFlagRequired("external-name") + return cmd +} + +// Complete completes all the required options +func (o *ServiceExternalNameOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.ServiceExternalNameGeneratorV1Name: + generator = &generateversioned.ServiceCommonGeneratorV1{ + Name: name, + Type: v1.ServiceTypeExternalName, + ExternalName: cmdutil.GetFlagString(cmd, "external-name"), + ClusterIP: "", + } + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in ServiceExternalNameOpts instance +func (o *ServiceExternalNameOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_service_test.go b/pkg/cmd/create/create_service_test.go new file mode 100644 index 000000000..5a3943f87 --- /dev/null +++ b/pkg/cmd/create/create_service_test.go @@ -0,0 +1,128 @@ +/* +Copyright 2016 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 create + +import ( + "net/http" + "testing" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreateService(t *testing.T) { + service := &v1.Service{} + service.Name = "my-service" + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + negSer := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: negSer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/services" && m == "POST": + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, service)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateServiceClusterIP(tf, ioStreams) + cmd.Flags().Set("output", "name") + cmd.Flags().Set("tcp", "8080:8000") + cmd.Run(cmd, []string{service.Name}) + expectedOutput := "service/" + service.Name + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} + +func TestCreateServiceNodePort(t *testing.T) { + service := &v1.Service{} + service.Name = "my-node-port-service" + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + negSer := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: negSer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/services" && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, service)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateServiceNodePort(tf, ioStreams) + cmd.Flags().Set("output", "name") + cmd.Flags().Set("tcp", "30000:8000") + cmd.Run(cmd, []string{service.Name}) + expectedOutput := "service/" + service.Name + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} + +func TestCreateServiceExternalName(t *testing.T) { + service := &v1.Service{} + service.Name = "my-external-name-service" + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + negSer := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: negSer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/services" && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, service)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateServiceExternalName(tf, ioStreams) + cmd.Flags().Set("output", "name") + cmd.Flags().Set("external-name", "name") + cmd.Run(cmd, []string{service.Name}) + expectedOutput := "service/" + service.Name + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} diff --git a/pkg/cmd/create/create_serviceaccount.go b/pkg/cmd/create/create_serviceaccount.go new file mode 100644 index 000000000..7dd2746a1 --- /dev/null +++ b/pkg/cmd/create/create_serviceaccount.go @@ -0,0 +1,92 @@ +/* +Copyright 2016 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 create + +import ( + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + serviceAccountLong = templates.LongDesc(i18n.T(` + Create a service account with the specified name.`)) + + serviceAccountExample = templates.Examples(i18n.T(` + # Create a new service account named my-service-account + kubectl create serviceaccount my-service-account`)) +) + +// ServiceAccountOpts holds the options for 'create serviceaccount' sub command +type ServiceAccountOpts struct { + CreateSubcommandOptions *CreateSubcommandOptions +} + +// NewCmdCreateServiceAccount is a macro command to create a new service account +func NewCmdCreateServiceAccount(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + options := &ServiceAccountOpts{ + CreateSubcommandOptions: NewCreateSubcommandOptions(ioStreams), + } + + cmd := &cobra.Command{ + Use: "serviceaccount NAME [--dry-run]", + DisableFlagsInUseLine: true, + Aliases: []string{"sa"}, + Short: i18n.T("Create a service account with the specified name"), + Long: serviceAccountLong, + Example: serviceAccountExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + options.CreateSubcommandOptions.PrintFlags.AddFlags(cmd) + + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddValidateFlags(cmd) + cmdutil.AddGeneratorFlags(cmd, generateversioned.ServiceAccountV1GeneratorName) + return cmd +} + +// Complete completes all the required options +func (o *ServiceAccountOpts) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + name, err := NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + var generator generate.StructuredGenerator + switch generatorName := cmdutil.GetFlagString(cmd, "generator"); generatorName { + case generateversioned.ServiceAccountV1GeneratorName: + generator = &generateversioned.ServiceAccountGeneratorV1{Name: name} + default: + return errUnsupportedGenerator(cmd, generatorName) + } + + return o.CreateSubcommandOptions.Complete(f, cmd, args, generator) +} + +// Run calls the CreateSubcommandOptions.Run in ServiceAccountOpts instance +func (o *ServiceAccountOpts) Run() error { + return o.CreateSubcommandOptions.Run() +} diff --git a/pkg/cmd/create/create_serviceaccount_test.go b/pkg/cmd/create/create_serviceaccount_test.go new file mode 100644 index 000000000..e9e549aad --- /dev/null +++ b/pkg/cmd/create/create_serviceaccount_test.go @@ -0,0 +1,61 @@ +/* +Copyright 2016 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 create + +import ( + "net/http" + "testing" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestCreateServiceAccount(t *testing.T) { + serviceAccountObject := &v1.ServiceAccount{} + serviceAccountObject.Name = "my-service-account" + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/serviceaccounts" && m == "POST": + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, serviceAccountObject)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateServiceAccount(tf, ioStreams) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{serviceAccountObject.Name}) + expectedOutput := "serviceaccount/" + serviceAccountObject.Name + "\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} diff --git a/pkg/cmd/create/create_test.go b/pkg/cmd/create/create_test.go new file mode 100644 index 000000000..669ac1002 --- /dev/null +++ b/pkg/cmd/create/create_test.go @@ -0,0 +1,151 @@ +/* +Copyright 2014 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 create + +import ( + "net/http" + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestExtraArgsFail(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + + f := cmdtesting.NewTestFactory() + defer f.Cleanup() + + c := NewCmdCreate(f, genericclioptions.NewTestIOStreamsDiscard()) + options := CreateOptions{} + if options.ValidateArgs(c, []string{"rc"}) == nil { + t.Errorf("unexpected non-error") + } +} + +func TestCreateObject(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, _, rc := cmdtesting.TestData() + rc.Items[0].Name = "redis-master-controller" + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/replicationcontrollers" && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreate(tf, ioStreams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + // uses the name from the file, not the response + if buf.String() != "replicationcontroller/redis-master-controller\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestCreateMultipleObject(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, svc, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/services" && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + case p == "/namespaces/test/replicationcontrollers" && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreate(tf, ioStreams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml") + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/frontend-service.yaml") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + // Names should come from the REST response, NOT the files + if buf.String() != "replicationcontroller/rc1\nservice/baz\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestCreateDirectory(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, _, rc := cmdtesting.TestData() + rc.Items[0].Name = "name" + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/replicationcontrollers" && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreate(tf, ioStreams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if buf.String() != "replicationcontroller/name\nreplicationcontroller/name\nreplicationcontroller/name\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} diff --git a/pkg/cmd/delete/delete.go b/pkg/cmd/delete/delete.go new file mode 100644 index 000000000..a21fbbf7d --- /dev/null +++ b/pkg/cmd/delete/delete.go @@ -0,0 +1,392 @@ +/* +Copyright 2014 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 delete + +import ( + "fmt" + "net/url" + "strings" + "time" + + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/dynamic" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/rawhttp" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + cmdwait "k8s.io/kubernetes/pkg/kubectl/cmd/wait" +) + +var ( + deleteLong = templates.LongDesc(i18n.T(` + Delete resources by filenames, stdin, resources and names, or by resources and label selector. + + JSON and YAML formats are accepted. Only one type of the arguments may be specified: filenames, + resources and names, or resources and label selector. + + Some resources, such as pods, support graceful deletion. These resources define a default period + before they are forcibly terminated (the grace period) but you may override that value with + the --grace-period flag, or pass --now to set a grace-period of 1. Because these resources often + represent entities in the cluster, deletion may not be acknowledged immediately. If the node + hosting a pod is down or cannot reach the API server, termination may take significantly longer + than the grace period. To force delete a resource, you must pass a grace period of 0 and specify + the --force flag. + + IMPORTANT: Force deleting pods does not wait for confirmation that the pod's processes have been + terminated, which can leave those processes running until the node detects the deletion and + completes graceful deletion. If your processes use shared storage or talk to a remote API and + depend on the name of the pod to identify themselves, force deleting those pods may result in + multiple processes running on different machines using the same identification which may lead + to data corruption or inconsistency. Only force delete pods when you are sure the pod is + terminated, or if your application can tolerate multiple copies of the same pod running at once. + Also, if you force delete pods the scheduler may place new pods on those nodes before the node + has released those resources and causing those pods to be evicted immediately. + + Note that the delete command does NOT do resource version checks, so if someone submits an + update to a resource right when you submit a delete, their update will be lost along with the + rest of the resource.`)) + + deleteExample = templates.Examples(i18n.T(` + # Delete a pod using the type and name specified in pod.json. + kubectl delete -f ./pod.json + + # Delete resources from a directory containing kustomization.yaml - e.g. dir/kustomization.yaml. + kubectl delete -k dir + + # Delete a pod based on the type and name in the JSON passed into stdin. + cat pod.json | kubectl delete -f - + + # Delete pods and services with same names "baz" and "foo" + kubectl delete pod,service baz foo + + # Delete pods and services with label name=myLabel. + kubectl delete pods,services -l name=myLabel + + # Delete a pod with minimal delay + kubectl delete pod foo --now + + # Force delete a pod on a dead node + kubectl delete pod foo --grace-period=0 --force + + # Delete all pods + kubectl delete pods --all`)) +) + +type DeleteOptions struct { + resource.FilenameOptions + + LabelSelector string + FieldSelector string + DeleteAll bool + DeleteAllNamespaces bool + IgnoreNotFound bool + Cascade bool + DeleteNow bool + ForceDeletion bool + WaitForDeletion bool + Quiet bool + WarnClusterScope bool + Raw string + + GracePeriod int + Timeout time.Duration + + Output string + + DynamicClient dynamic.Interface + Mapper meta.RESTMapper + Result *resource.Result + + genericclioptions.IOStreams +} + +func NewCmdDelete(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + deleteFlags := NewDeleteCommandFlags("containing the resource to delete.") + + cmd := &cobra.Command{ + Use: "delete ([-f FILENAME] | [-k DIRECTORY] | TYPE [(NAME | -l label | --all)])", + DisableFlagsInUseLine: true, + Short: i18n.T("Delete resources by filenames, stdin, resources and names, or by resources and label selector"), + Long: deleteLong, + Example: deleteExample, + Run: func(cmd *cobra.Command, args []string) { + o := deleteFlags.ToOptions(nil, streams) + cmdutil.CheckErr(o.Complete(f, args, cmd)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunDelete(f)) + }, + SuggestFor: []string{"rm"}, + } + + deleteFlags.AddFlags(cmd) + + cmdutil.AddIncludeUninitializedFlag(cmd) + return cmd +} + +func (o *DeleteOptions) Complete(f cmdutil.Factory, args []string, cmd *cobra.Command) error { + cmdNamespace, enforceNamespace, err := f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.WarnClusterScope = enforceNamespace && !o.DeleteAllNamespaces + + if o.DeleteAll || len(o.LabelSelector) > 0 || len(o.FieldSelector) > 0 { + if f := cmd.Flags().Lookup("ignore-not-found"); f != nil && !f.Changed { + // If the user didn't explicitly set the option, default to ignoring NotFound errors when used with --all, -l, or --field-selector + o.IgnoreNotFound = true + } + } + if o.DeleteNow { + if o.GracePeriod != -1 { + return fmt.Errorf("--now and --grace-period cannot be specified together") + } + o.GracePeriod = 1 + } + if o.GracePeriod == 0 && !o.ForceDeletion { + // To preserve backwards compatibility, but prevent accidental data loss, we convert --grace-period=0 + // into --grace-period=1. Users may provide --force to bypass this conversion. + o.GracePeriod = 1 + } + + if len(o.Raw) == 0 { + r := f.NewBuilder(). + Unstructured(). + ContinueOnError(). + NamespaceParam(cmdNamespace).DefaultNamespace(). + FilenameParam(enforceNamespace, &o.FilenameOptions). + LabelSelectorParam(o.LabelSelector). + FieldSelectorParam(o.FieldSelector). + SelectAllParam(o.DeleteAll). + AllNamespaces(o.DeleteAllNamespaces). + ResourceTypeOrNameArgs(false, args...).RequireObject(false). + Flatten(). + Do() + err = r.Err() + if err != nil { + return err + } + o.Result = r + + o.Mapper, err = f.ToRESTMapper() + if err != nil { + return err + } + + o.DynamicClient, err = f.DynamicClient() + if err != nil { + return err + } + } + + return nil +} + +func (o *DeleteOptions) Validate() error { + if o.Output != "" && o.Output != "name" { + return fmt.Errorf("unexpected -o output mode: %v. We only support '-o name'", o.Output) + } + + if o.DeleteAll && len(o.LabelSelector) > 0 { + return fmt.Errorf("cannot set --all and --selector at the same time") + } + if o.DeleteAll && len(o.FieldSelector) > 0 { + return fmt.Errorf("cannot set --all and --field-selector at the same time") + } + + switch { + case o.GracePeriod == 0 && o.ForceDeletion: + fmt.Fprintf(o.ErrOut, "warning: Immediate deletion does not wait for confirmation that the running resource has been terminated. The resource may continue to run on the cluster indefinitely.\n") + case o.ForceDeletion: + fmt.Fprintf(o.ErrOut, "warning: --force is ignored because --grace-period is not 0.\n") + } + + if len(o.Raw) > 0 { + if len(o.FilenameOptions.Filenames) > 1 { + return fmt.Errorf("--raw can only use a single local file or stdin") + } else if len(o.FilenameOptions.Filenames) == 1 { + if strings.Index(o.FilenameOptions.Filenames[0], "http://") == 0 || strings.Index(o.FilenameOptions.Filenames[0], "https://") == 0 { + return fmt.Errorf("--raw cannot read from a url") + } + } + + if o.FilenameOptions.Recursive { + return fmt.Errorf("--raw and --recursive are mutually exclusive") + } + if len(o.Output) > 0 { + return fmt.Errorf("--raw and --output are mutually exclusive") + } + if _, err := url.ParseRequestURI(o.Raw); err != nil { + return fmt.Errorf("--raw must be a valid URL path: %v", err) + } + } + + return nil +} + +func (o *DeleteOptions) RunDelete(f cmdutil.Factory) error { + if len(o.Raw) > 0 { + restClient, err := f.RESTClient() + if err != nil { + return err + } + if len(o.Filenames) == 0 { + return rawhttp.RawDelete(restClient, o.IOStreams, o.Raw, "") + } + return rawhttp.RawDelete(restClient, o.IOStreams, o.Raw, o.Filenames[0]) + } + return o.DeleteResult(o.Result) +} + +func (o *DeleteOptions) DeleteResult(r *resource.Result) error { + found := 0 + if o.IgnoreNotFound { + r = r.IgnoreErrors(errors.IsNotFound) + } + warnClusterScope := o.WarnClusterScope + deletedInfos := []*resource.Info{} + uidMap := cmdwait.UIDMap{} + err := r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + deletedInfos = append(deletedInfos, info) + found++ + + options := &metav1.DeleteOptions{} + if o.GracePeriod >= 0 { + options = metav1.NewDeleteOptions(int64(o.GracePeriod)) + } + policy := metav1.DeletePropagationBackground + if !o.Cascade { + policy = metav1.DeletePropagationOrphan + } + options.PropagationPolicy = &policy + + if warnClusterScope && info.Mapping.Scope.Name() == meta.RESTScopeNameRoot { + fmt.Fprintf(o.ErrOut, "warning: deleting cluster-scoped resources, not scoped to the provided namespace\n") + warnClusterScope = false + } + response, err := o.deleteResource(info, options) + if err != nil { + return err + } + resourceLocation := cmdwait.ResourceLocation{ + GroupResource: info.Mapping.Resource.GroupResource(), + Namespace: info.Namespace, + Name: info.Name, + } + if status, ok := response.(*metav1.Status); ok && status.Details != nil { + uidMap[resourceLocation] = status.Details.UID + return nil + } + responseMetadata, err := meta.Accessor(response) + if err != nil { + // we don't have UID, but we didn't fail the delete, next best thing is just skipping the UID + klog.V(1).Info(err) + return nil + } + uidMap[resourceLocation] = responseMetadata.GetUID() + + return nil + }) + if err != nil { + return err + } + if found == 0 { + fmt.Fprintf(o.Out, "No resources found\n") + return nil + } + if !o.WaitForDeletion { + return nil + } + // if we don't have a dynamic client, we don't want to wait. Eventually when delete is cleaned up, this will likely + // drop out. + if o.DynamicClient == nil { + return nil + } + + effectiveTimeout := o.Timeout + if effectiveTimeout == 0 { + // if we requested to wait forever, set it to a week. + effectiveTimeout = 168 * time.Hour + } + waitOptions := cmdwait.WaitOptions{ + ResourceFinder: genericclioptions.ResourceFinderForResult(resource.InfoListVisitor(deletedInfos)), + UIDMap: uidMap, + DynamicClient: o.DynamicClient, + Timeout: effectiveTimeout, + + Printer: printers.NewDiscardingPrinter(), + ConditionFn: cmdwait.IsDeleted, + IOStreams: o.IOStreams, + } + err = waitOptions.RunWait() + if errors.IsForbidden(err) || errors.IsMethodNotSupported(err) { + // if we're forbidden from waiting, we shouldn't fail. + // if the resource doesn't support a verb we need, we shouldn't fail. + klog.V(1).Info(err) + return nil + } + return err +} + +func (o *DeleteOptions) deleteResource(info *resource.Info, deleteOptions *metav1.DeleteOptions) (runtime.Object, error) { + deleteResponse, err := resource.NewHelper(info.Client, info.Mapping).DeleteWithOptions(info.Namespace, info.Name, deleteOptions) + if err != nil { + return nil, cmdutil.AddSourceToErr("deleting", info.Source, err) + } + + if !o.Quiet { + o.PrintObj(info) + } + return deleteResponse, nil +} + +// PrintObj for deleted objects is special because we do not have an object to print. +// This mirrors name printer behavior +func (o *DeleteOptions) PrintObj(info *resource.Info) { + operation := "deleted" + groupKind := info.Mapping.GroupVersionKind + kindString := fmt.Sprintf("%s.%s", strings.ToLower(groupKind.Kind), groupKind.Group) + if len(groupKind.Group) == 0 { + kindString = strings.ToLower(groupKind.Kind) + } + + if o.GracePeriod == 0 { + operation = "force deleted" + } + + if o.Output == "name" { + // -o name: prints resource/name + fmt.Fprintf(o.Out, "%s/%s\n", kindString, info.Name) + return + } + + // understandable output by default + fmt.Fprintf(o.Out, "%s \"%s\" %s\n", kindString, info.Name, operation) +} diff --git a/pkg/cmd/delete/delete_flags.go b/pkg/cmd/delete/delete_flags.go new file mode 100644 index 000000000..d34104f51 --- /dev/null +++ b/pkg/cmd/delete/delete_flags.go @@ -0,0 +1,214 @@ +/* +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 delete + +import ( + "time" + + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/dynamic" +) + +// DeleteFlags composes common printer flag structs +// used for commands requiring deletion logic. +type DeleteFlags struct { + FileNameFlags *genericclioptions.FileNameFlags + LabelSelector *string + FieldSelector *string + + All *bool + AllNamespaces *bool + Cascade *bool + Force *bool + GracePeriod *int + IgnoreNotFound *bool + Now *bool + Timeout *time.Duration + Wait *bool + Output *string + Raw *string +} + +func (f *DeleteFlags) ToOptions(dynamicClient dynamic.Interface, streams genericclioptions.IOStreams) *DeleteOptions { + options := &DeleteOptions{ + DynamicClient: dynamicClient, + IOStreams: streams, + } + + // add filename options + if f.FileNameFlags != nil { + options.FilenameOptions = f.FileNameFlags.ToOptions() + } + if f.LabelSelector != nil { + options.LabelSelector = *f.LabelSelector + } + if f.FieldSelector != nil { + options.FieldSelector = *f.FieldSelector + } + + // add output format + if f.Output != nil { + options.Output = *f.Output + } + + if f.All != nil { + options.DeleteAll = *f.All + } + if f.AllNamespaces != nil { + options.DeleteAllNamespaces = *f.AllNamespaces + } + if f.Cascade != nil { + options.Cascade = *f.Cascade + } + if f.Force != nil { + options.ForceDeletion = *f.Force + } + if f.GracePeriod != nil { + options.GracePeriod = *f.GracePeriod + } + if f.IgnoreNotFound != nil { + options.IgnoreNotFound = *f.IgnoreNotFound + } + if f.Now != nil { + options.DeleteNow = *f.Now + } + if f.Timeout != nil { + options.Timeout = *f.Timeout + } + if f.Wait != nil { + options.WaitForDeletion = *f.Wait + } + if f.Raw != nil { + options.Raw = *f.Raw + } + + return options +} + +func (f *DeleteFlags) AddFlags(cmd *cobra.Command) { + f.FileNameFlags.AddFlags(cmd.Flags()) + if f.LabelSelector != nil { + cmd.Flags().StringVarP(f.LabelSelector, "selector", "l", *f.LabelSelector, "Selector (label query) to filter on, not including uninitialized ones.") + } + if f.FieldSelector != nil { + cmd.Flags().StringVarP(f.FieldSelector, "field-selector", "", *f.FieldSelector, "Selector (field query) to filter on, supports '=', '==', and '!='.(e.g. --field-selector key1=value1,key2=value2). The server only supports a limited number of field queries per type.") + } + if f.All != nil { + cmd.Flags().BoolVar(f.All, "all", *f.All, "Delete all resources, including uninitialized ones, in the namespace of the specified resource types.") + } + if f.AllNamespaces != nil { + cmd.Flags().BoolVarP(f.AllNamespaces, "all-namespaces", "A", *f.AllNamespaces, "If present, list the requested object(s) across all namespaces. Namespace in current context is ignored even if specified with --namespace.") + } + if f.Force != nil { + cmd.Flags().BoolVar(f.Force, "force", *f.Force, "Only used when grace-period=0. If true, immediately remove resources from API and bypass graceful deletion. Note that immediate deletion of some resources may result in inconsistency or data loss and requires confirmation.") + } + if f.Cascade != nil { + cmd.Flags().BoolVar(f.Cascade, "cascade", *f.Cascade, "If true, cascade the deletion of the resources managed by this resource (e.g. Pods created by a ReplicationController). Default true.") + } + if f.Now != nil { + cmd.Flags().BoolVar(f.Now, "now", *f.Now, "If true, resources are signaled for immediate shutdown (same as --grace-period=1).") + } + if f.GracePeriod != nil { + cmd.Flags().IntVar(f.GracePeriod, "grace-period", *f.GracePeriod, "Period of time in seconds given to the resource to terminate gracefully. Ignored if negative. Set to 1 for immediate shutdown. Can only be set to 0 when --force is true (force deletion).") + } + if f.Timeout != nil { + cmd.Flags().DurationVar(f.Timeout, "timeout", *f.Timeout, "The length of time to wait before giving up on a delete, zero means determine a timeout from the size of the object") + } + if f.IgnoreNotFound != nil { + cmd.Flags().BoolVar(f.IgnoreNotFound, "ignore-not-found", *f.IgnoreNotFound, "Treat \"resource not found\" as a successful delete. Defaults to \"true\" when --all is specified.") + } + if f.Wait != nil { + cmd.Flags().BoolVar(f.Wait, "wait", *f.Wait, "If true, wait for resources to be gone before returning. This waits for finalizers.") + } + if f.Output != nil { + cmd.Flags().StringVarP(f.Output, "output", "o", *f.Output, "Output mode. Use \"-o name\" for shorter output (resource/name).") + } + if f.Raw != nil { + cmd.Flags().StringVar(f.Raw, "raw", *f.Raw, "Raw URI to DELETE to the server. Uses the transport specified by the kubeconfig file.") + } +} + +// NewDeleteCommandFlags provides default flags and values for use with the "delete" command +func NewDeleteCommandFlags(usage string) *DeleteFlags { + cascade := true + gracePeriod := -1 + + // setup command defaults + all := false + allNamespaces := false + force := false + ignoreNotFound := false + now := false + output := "" + labelSelector := "" + fieldSelector := "" + timeout := time.Duration(0) + wait := true + raw := "" + + filenames := []string{} + recursive := false + kustomize := "" + + return &DeleteFlags{ + // Not using helpers.go since it provides function to add '-k' for FileNameOptions, but not FileNameFlags + FileNameFlags: &genericclioptions.FileNameFlags{Usage: usage, Filenames: &filenames, Kustomize: &kustomize, Recursive: &recursive}, + LabelSelector: &labelSelector, + FieldSelector: &fieldSelector, + + Cascade: &cascade, + GracePeriod: &gracePeriod, + + All: &all, + AllNamespaces: &allNamespaces, + Force: &force, + IgnoreNotFound: &ignoreNotFound, + Now: &now, + Timeout: &timeout, + Wait: &wait, + Output: &output, + Raw: &raw, + } +} + +// NewDeleteFlags provides default flags and values for use in commands outside of "delete" +func NewDeleteFlags(usage string) *DeleteFlags { + cascade := true + gracePeriod := -1 + + force := false + timeout := time.Duration(0) + wait := false + + filenames := []string{} + kustomize := "" + recursive := false + + return &DeleteFlags{ + FileNameFlags: &genericclioptions.FileNameFlags{Usage: usage, Filenames: &filenames, Kustomize: &kustomize, Recursive: &recursive}, + + Cascade: &cascade, + GracePeriod: &gracePeriod, + + // add non-defaults + Force: &force, + Timeout: &timeout, + Wait: &wait, + } +} diff --git a/pkg/cmd/delete/delete_test.go b/pkg/cmd/delete/delete_test.go new file mode 100644 index 000000000..7daf82946 --- /dev/null +++ b/pkg/cmd/delete/delete_test.go @@ -0,0 +1,713 @@ +/* +Copyright 2014 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 delete + +import ( + "encoding/json" + "io" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func fakecmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ([-f FILENAME] | TYPE [(NAME | -l label | --all)])", + DisableFlagsInUseLine: true, + Run: func(cmd *cobra.Command, args []string) {}, + } + return cmd +} + +func TestDeleteObjectByTuple(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, _, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + + // replication controller with cascade off + case p == "/namespaces/test/replicationcontrollers/redis-master-controller" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + + // secret with cascade on, but no client-side reaper + case p == "/namespaces/test/secrets/mysecret" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + + default: + // Ensures no GET is performed when deleting by name + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{"replicationcontrollers/redis-master-controller"}) + if buf.String() != "replicationcontroller/redis-master-controller\n" { + t.Errorf("unexpected output: %s", buf.String()) + } + + // Test cascading delete of object without client-side reaper doesn't make GET requests + streams, _, buf, _ = genericclioptions.NewTestIOStreams() + cmd = NewCmdDelete(tf, streams) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{"secrets/mysecret"}) + if buf.String() != "secret/mysecret\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func hasExpectedPropagationPolicy(body io.ReadCloser, policy *metav1.DeletionPropagation) bool { + if body == nil || policy == nil { + return body == nil && policy == nil + } + var parsedBody metav1.DeleteOptions + rawBody, _ := ioutil.ReadAll(body) + json.Unmarshal(rawBody, &parsedBody) + if parsedBody.PropagationPolicy == nil { + return false + } + return *policy == *parsedBody.PropagationPolicy +} + +// Tests that DeleteOptions.OrphanDependents is appropriately set while deleting objects. +func TestOrphanDependentsInDeleteObject(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, _, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + var policy *metav1.DeletionPropagation + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m, b := req.URL.Path, req.Method, req.Body; { + + case p == "/namespaces/test/secrets/mysecret" && m == "DELETE" && hasExpectedPropagationPolicy(b, policy): + + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + default: + return nil, nil + } + }), + } + + // DeleteOptions.PropagationPolicy should be Background, when cascade is true (default). + backgroundPolicy := metav1.DeletePropagationBackground + policy = &backgroundPolicy + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{"secrets/mysecret"}) + if buf.String() != "secret/mysecret\n" { + t.Errorf("unexpected output: %s", buf.String()) + } + + // Test that delete options should be set to orphan when cascade is false. + orphanPolicy := metav1.DeletePropagationOrphan + policy = &orphanPolicy + streams, _, buf, _ = genericclioptions.NewTestIOStreams() + cmd = NewCmdDelete(tf, streams) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{"secrets/mysecret"}) + if buf.String() != "secret/mysecret\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDeleteNamedObject(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + cmdtesting.InitTestErrorHandler(t) + _, _, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + + // replication controller with cascade off + case p == "/namespaces/test/replicationcontrollers/redis-master-controller" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + + // secret with cascade on, but no client-side reaper + case p == "/namespaces/test/secrets/mysecret" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + + default: + // Ensures no GET is performed when deleting by name + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{"replicationcontrollers", "redis-master-controller"}) + if buf.String() != "replicationcontroller/redis-master-controller\n" { + t.Errorf("unexpected output: %s", buf.String()) + } + + // Test cascading delete of object without client-side reaper doesn't make GET requests + streams, _, buf, _ = genericclioptions.NewTestIOStreams() + cmd = NewCmdDelete(tf, streams) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{"secrets", "mysecret"}) + if buf.String() != "secret/mysecret\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDeleteObject(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, _, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + // uses the name from the file, not the response + if buf.String() != "replicationcontroller/redis-master\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDeleteObjectGraceZero(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + pods, _, _ := cmdtesting.TestData() + + count := 0 + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Logf("got request %s %s", req.Method, req.URL.Path) + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/pods/nginx" && m == "GET": + count++ + switch count { + case 1, 2, 3: + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + default: + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &metav1.Status{})}, nil + } + case p == "/api/v1/namespaces/test" && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.Namespace{})}, nil + case p == "/namespaces/test/pods/nginx" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + streams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("output", "name") + cmd.Flags().Set("grace-period", "0") + cmd.Run(cmd, []string{"pods/nginx"}) + + // uses the name from the file, not the response + if buf.String() != "pod/nginx\n" { + t.Errorf("unexpected output: %s\n---\n%s", buf.String(), errBuf.String()) + } + if count != 0 { + t.Errorf("unexpected calls to GET: %d", count) + } +} + +func TestDeleteObjectNotFound(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == "DELETE": + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + options := &DeleteOptions{ + FilenameOptions: resource.FilenameOptions{ + Filenames: []string{"../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml"}, + }, + GracePeriod: -1, + Cascade: false, + Output: "name", + IOStreams: genericclioptions.NewTestIOStreamsDiscard(), + } + err := options.Complete(tf, []string{}, fakecmd()) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + err = options.RunDelete(nil) + if err == nil || !errors.IsNotFound(err) { + t.Errorf("unexpected error: expected NotFound, got %v", err) + } +} + +func TestDeleteObjectIgnoreNotFound(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == "DELETE": + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("ignore-not-found", "true") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if buf.String() != "" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDeleteAllNotFound(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, svc, _ := cmdtesting.TestData() + // Add an item to the list which will result in a 404 on delete + svc.Items = append(svc.Items, corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}) + notFoundError := &errors.NewNotFound(corev1.Resource("services"), "foo").ErrStatus + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/services" && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, svc)}, nil + case p == "/namespaces/test/services/foo" && m == "DELETE": + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, notFoundError)}, nil + case p == "/namespaces/test/services/baz" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + // Make sure we can explicitly choose to fail on NotFound errors, even with --all + options := &DeleteOptions{ + FilenameOptions: resource.FilenameOptions{}, + GracePeriod: -1, + Cascade: false, + DeleteAll: true, + IgnoreNotFound: false, + Output: "name", + IOStreams: genericclioptions.NewTestIOStreamsDiscard(), + } + err := options.Complete(tf, []string{"services"}, fakecmd()) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + err = options.RunDelete(nil) + if err == nil || !errors.IsNotFound(err) { + t.Errorf("unexpected error: expected NotFound, got %v", err) + } +} + +func TestDeleteAllIgnoreNotFound(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, svc, _ := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + // Add an item to the list which will result in a 404 on delete + svc.Items = append(svc.Items, corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}) + notFoundError := &errors.NewNotFound(corev1.Resource("services"), "foo").ErrStatus + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/services" && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, svc)}, nil + case p == "/namespaces/test/services/foo" && m == "DELETE": + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, notFoundError)}, nil + case p == "/namespaces/test/services/baz" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("all", "true") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{"services"}) + + if buf.String() != "service/baz\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDeleteMultipleObject(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, svc, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case p == "/namespaces/test/services/frontend" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml") + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/frontend-service.yaml") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if buf.String() != "replicationcontroller/redis-master\nservice/frontend\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDeleteMultipleObjectContinueOnMissing(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, svc, _ := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == "DELETE": + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, nil + case p == "/namespaces/test/services/frontend" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + options := &DeleteOptions{ + FilenameOptions: resource.FilenameOptions{ + Filenames: []string{"../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml", "../../../../test/e2e/testing-manifests/guestbook/frontend-service.yaml"}, + }, + GracePeriod: -1, + Cascade: false, + Output: "name", + IOStreams: streams, + } + err := options.Complete(tf, []string{}, fakecmd()) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + err = options.RunDelete(nil) + if err == nil || !errors.IsNotFound(err) { + t.Errorf("unexpected error: expected NotFound, got %v", err) + } + + if buf.String() != "service/frontend\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDeleteMultipleResourcesWithTheSameName(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, svc, rc := cmdtesting.TestData() + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/replicationcontrollers/baz" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case p == "/namespaces/test/replicationcontrollers/foo" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case p == "/namespaces/test/services/baz" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + case p == "/namespaces/test/services/foo" && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + default: + // Ensures no GET is performed when deleting by name + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{"replicationcontrollers,services", "baz", "foo"}) + if buf.String() != "replicationcontroller/baz\nreplicationcontroller/foo\nservice/baz\nservice/foo\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDeleteDirectory(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + _, _, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case strings.HasPrefix(p, "/namespaces/test/replicationcontrollers/") && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if buf.String() != "replicationcontroller/frontend\nreplicationcontroller/redis-master\nreplicationcontroller/redis-slave\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDeleteMultipleSelector(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + pods, svc, _ := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/pods" && m == "GET": + if req.URL.Query().Get(metav1.LabelSelectorQueryParam("v1")) != "a=b" { + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, pods)}, nil + case p == "/namespaces/test/services" && m == "GET": + if req.URL.Query().Get(metav1.LabelSelectorQueryParam("v1")) != "a=b" { + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, svc)}, nil + case strings.HasPrefix(p, "/namespaces/test/pods/") && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + case strings.HasPrefix(p, "/namespaces/test/services/") && m == "DELETE": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDelete(tf, streams) + cmd.Flags().Set("selector", "a=b") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{"pods,services"}) + + if buf.String() != "pod/foo\npod/bar\nservice/baz\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestResourceErrors(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + testCases := map[string]struct { + args []string + errFn func(error) bool + }{ + "no args": { + args: []string{}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "You must provide one or more resources") }, + }, + "resources but no selectors": { + args: []string{"pods"}, + errFn: func(err error) bool { + return strings.Contains(err.Error(), "resource(s) were provided, but no name, label selector, or --all flag specified") + }, + }, + "multiple resources but no selectors": { + args: []string{"pods,deployments"}, + errFn: func(err error) bool { + return strings.Contains(err.Error(), "resource(s) were provided, but no name, label selector, or --all flag specified") + }, + }, + } + + for k, testCase := range testCases { + t.Run(k, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + options := &DeleteOptions{ + FilenameOptions: resource.FilenameOptions{}, + GracePeriod: -1, + Cascade: false, + Output: "name", + IOStreams: streams, + } + err := options.Complete(tf, testCase.args, fakecmd()) + if !testCase.errFn(err) { + t.Errorf("%s: unexpected error: %v", k, err) + return + } + + if buf.Len() > 0 { + t.Errorf("buffer should be empty: %s", string(buf.Bytes())) + } + }) + } +} diff --git a/pkg/cmd/describe/describe.go b/pkg/cmd/describe/describe.go new file mode 100644 index 000000000..bc5677d5e --- /dev/null +++ b/pkg/cmd/describe/describe.go @@ -0,0 +1,246 @@ +/* +Copyright 2014 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 describe + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/describe" + describeversioned "k8s.io/kubectl/pkg/describe/versioned" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + describeLong = templates.LongDesc(` + Show details of a specific resource or group of resources + + Print a detailed description of the selected resources, including related resources such + as events or controllers. You may select a single object by name, all objects of that + type, provide a name prefix, or label selector. For example: + + $ kubectl describe TYPE NAME_PREFIX + + will first check for an exact match on TYPE and NAME_PREFIX. If no such resource + exists, it will output details for every resource that has a name prefixed with NAME_PREFIX.`) + + describeExample = templates.Examples(i18n.T(` + # Describe a node + kubectl describe nodes kubernetes-node-emt8.c.myproject.internal + + # Describe a pod + kubectl describe pods/nginx + + # Describe a pod identified by type and name in "pod.json" + kubectl describe -f pod.json + + # Describe all pods + kubectl describe pods + + # Describe pods by label name=myLabel + kubectl describe po -l name=myLabel + + # Describe all pods managed by the 'frontend' replication controller (rc-created pods + # get the name of the rc as a prefix in the pod the name). + kubectl describe pods frontend`)) +) + +type DescribeOptions struct { + CmdParent string + Selector string + Namespace string + + Describer func(*meta.RESTMapping) (describe.Describer, error) + NewBuilder func() *resource.Builder + + BuilderArgs []string + + EnforceNamespace bool + AllNamespaces bool + + DescriberSettings *describe.DescriberSettings + FilenameOptions *resource.FilenameOptions + + genericclioptions.IOStreams +} + +func NewCmdDescribe(parent string, f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := &DescribeOptions{ + FilenameOptions: &resource.FilenameOptions{}, + DescriberSettings: &describe.DescriberSettings{ + ShowEvents: true, + }, + + CmdParent: parent, + + IOStreams: streams, + } + + cmd := &cobra.Command{ + Use: "describe (-f FILENAME | TYPE [NAME_PREFIX | -l label] | TYPE/NAME)", + DisableFlagsInUseLine: true, + Short: i18n.T("Show details of a specific resource or group of resources"), + Long: describeLong + "\n\n" + cmdutil.SuggestAPIResources(parent), + Example: describeExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Run()) + }, + } + usage := "containing the resource to describe" + cmdutil.AddFilenameOptionFlags(cmd, o.FilenameOptions, usage) + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().BoolVarP(&o.AllNamespaces, "all-namespaces", "A", o.AllNamespaces, "If present, list the requested object(s) across all namespaces. Namespace in current context is ignored even if specified with --namespace.") + cmd.Flags().BoolVar(&o.DescriberSettings.ShowEvents, "show-events", o.DescriberSettings.ShowEvents, "If true, display events related to the described object.") + cmdutil.AddIncludeUninitializedFlag(cmd) + return cmd +} + +func (o *DescribeOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + if o.AllNamespaces { + o.EnforceNamespace = false + } + + if len(args) == 0 && cmdutil.IsFilenameSliceEmpty(o.FilenameOptions.Filenames, o.FilenameOptions.Kustomize) { + return fmt.Errorf("You must specify the type of resource to describe. %s\n", cmdutil.SuggestAPIResources(o.CmdParent)) + } + + o.BuilderArgs = args + + o.Describer = func(mapping *meta.RESTMapping) (describe.Describer, error) { + return describeversioned.DescriberFn(f, mapping) + } + + o.NewBuilder = f.NewBuilder + + return nil +} + +func (o *DescribeOptions) Validate(args []string) error { + return nil +} + +func (o *DescribeOptions) Run() error { + r := o.NewBuilder(). + Unstructured(). + ContinueOnError(). + NamespaceParam(o.Namespace).DefaultNamespace().AllNamespaces(o.AllNamespaces). + FilenameParam(o.EnforceNamespace, o.FilenameOptions). + LabelSelectorParam(o.Selector). + ResourceTypeOrNameArgs(true, o.BuilderArgs...). + Flatten(). + Do() + err := r.Err() + if err != nil { + return err + } + + allErrs := []error{} + infos, err := r.Infos() + if err != nil { + if apierrors.IsNotFound(err) && len(o.BuilderArgs) == 2 { + return o.DescribeMatchingResources(err, o.BuilderArgs[0], o.BuilderArgs[1]) + } + allErrs = append(allErrs, err) + } + + errs := sets.NewString() + first := true + for _, info := range infos { + mapping := info.ResourceMapping() + describer, err := o.Describer(mapping) + if err != nil { + if errs.Has(err.Error()) { + continue + } + allErrs = append(allErrs, err) + errs.Insert(err.Error()) + continue + } + s, err := describer.Describe(info.Namespace, info.Name, *o.DescriberSettings) + if err != nil { + if errs.Has(err.Error()) { + continue + } + allErrs = append(allErrs, err) + errs.Insert(err.Error()) + continue + } + if first { + first = false + fmt.Fprint(o.Out, s) + } else { + fmt.Fprintf(o.Out, "\n\n%s", s) + } + } + + return utilerrors.NewAggregate(allErrs) +} + +func (o *DescribeOptions) DescribeMatchingResources(originalError error, resource, prefix string) error { + r := o.NewBuilder(). + Unstructured(). + NamespaceParam(o.Namespace).DefaultNamespace(). + ResourceTypeOrNameArgs(true, resource). + SingleResourceType(). + Flatten(). + Do() + mapping, err := r.ResourceMapping() + if err != nil { + return err + } + describer, err := o.Describer(mapping) + if err != nil { + return err + } + infos, err := r.Infos() + if err != nil { + return err + } + isFound := false + for ix := range infos { + info := infos[ix] + if strings.HasPrefix(info.Name, prefix) { + isFound = true + s, err := describer.Describe(info.Namespace, info.Name, *o.DescriberSettings) + if err != nil { + return err + } + fmt.Fprintf(o.Out, "%s\n", s) + } + } + if !isFound { + return originalError + } + return nil +} diff --git a/pkg/cmd/describe/describe_test.go b/pkg/cmd/describe/describe_test.go new file mode 100644 index 000000000..294c31150 --- /dev/null +++ b/pkg/cmd/describe/describe_test.go @@ -0,0 +1,262 @@ +/* +Copyright 2014 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 describe + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/describe" + versioneddescribe "k8s.io/kubectl/pkg/describe/versioned" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +// Verifies that schemas that are not in the master tree of Kubernetes can be retrieved via Get. +func TestDescribeUnknownSchemaObject(t *testing.T) { + d := &testDescriber{Output: "test output"} + oldFn := versioneddescribe.DescriberFn + defer func() { + versioneddescribe.DescriberFn = oldFn + }() + versioneddescribe.DescriberFn = d.describerFor + + tf := cmdtesting.NewTestFactory().WithNamespace("non-default") + defer tf.Cleanup() + _, _, codec := cmdtesting.NewExternalScheme() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Resp: &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, cmdtesting.NewInternalType("", "", "foo"))}, + } + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDescribe("kubectl", tf, streams) + cmd.Run(cmd, []string{"type", "foo"}) + + if d.Name != "foo" || d.Namespace != "" { + t.Errorf("unexpected describer: %#v", d) + } + + if buf.String() != fmt.Sprintf("%s", d.Output) { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +// Verifies that schemas that are not in the master tree of Kubernetes can be retrieved via Get. +func TestDescribeUnknownNamespacedSchemaObject(t *testing.T) { + d := &testDescriber{Output: "test output"} + oldFn := versioneddescribe.DescriberFn + defer func() { + versioneddescribe.DescriberFn = oldFn + }() + versioneddescribe.DescriberFn = d.describerFor + + tf := cmdtesting.NewTestFactory() + defer tf.Cleanup() + _, _, codec := cmdtesting.NewExternalScheme() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Resp: &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, cmdtesting.NewInternalNamespacedType("", "", "foo", "non-default"))}, + } + tf.WithNamespace("non-default") + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDescribe("kubectl", tf, streams) + cmd.Run(cmd, []string{"namespacedtype", "foo"}) + + if d.Name != "foo" || d.Namespace != "non-default" { + t.Errorf("unexpected describer: %#v", d) + } + + if buf.String() != fmt.Sprintf("%s", d.Output) { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDescribeObject(t *testing.T) { + d := &testDescriber{Output: "test output"} + oldFn := versioneddescribe.DescriberFn + defer func() { + versioneddescribe.DescriberFn = oldFn + }() + versioneddescribe.DescriberFn = d.describerFor + + _, _, rc := cmdtesting.TestData() + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDescribe("kubectl", tf, streams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml") + cmd.Run(cmd, []string{}) + + if d.Name != "redis-master" || d.Namespace != "test" { + t.Errorf("unexpected describer: %#v", d) + } + + if buf.String() != fmt.Sprintf("%s", d.Output) { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDescribeListObjects(t *testing.T) { + d := &testDescriber{Output: "test output"} + oldFn := versioneddescribe.DescriberFn + defer func() { + versioneddescribe.DescriberFn = oldFn + }() + versioneddescribe.DescriberFn = d.describerFor + + pods, _, _ := cmdtesting.TestData() + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Resp: &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, pods)}, + } + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDescribe("kubectl", tf, streams) + cmd.Run(cmd, []string{"pods"}) + if buf.String() != fmt.Sprintf("%s\n\n%s", d.Output, d.Output) { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestDescribeObjectShowEvents(t *testing.T) { + d := &testDescriber{Output: "test output"} + oldFn := versioneddescribe.DescriberFn + defer func() { + versioneddescribe.DescriberFn = oldFn + }() + versioneddescribe.DescriberFn = d.describerFor + + pods, _, _ := cmdtesting.TestData() + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Resp: &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, pods)}, + } + + cmd := NewCmdDescribe("kubectl", tf, genericclioptions.NewTestIOStreamsDiscard()) + cmd.Flags().Set("show-events", "true") + cmd.Run(cmd, []string{"pods"}) + if d.Settings.ShowEvents != true { + t.Errorf("ShowEvents = true expected, got ShowEvents = %v", d.Settings.ShowEvents) + } +} + +func TestDescribeObjectSkipEvents(t *testing.T) { + d := &testDescriber{Output: "test output"} + oldFn := versioneddescribe.DescriberFn + defer func() { + versioneddescribe.DescriberFn = oldFn + }() + versioneddescribe.DescriberFn = d.describerFor + + pods, _, _ := cmdtesting.TestData() + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Resp: &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, pods)}, + } + + cmd := NewCmdDescribe("kubectl", tf, genericclioptions.NewTestIOStreamsDiscard()) + cmd.Flags().Set("show-events", "false") + cmd.Run(cmd, []string{"pods"}) + if d.Settings.ShowEvents != false { + t.Errorf("ShowEvents = false expected, got ShowEvents = %v", d.Settings.ShowEvents) + } +} + +func TestDescribeHelpMessage(t *testing.T) { + tf := cmdtesting.NewTestFactory() + defer tf.Cleanup() + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdDescribe("kubectl", tf, streams) + cmd.SetArgs([]string{"-h"}) + cmd.SetOutput(buf) + _, err := cmd.ExecuteC() + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + got := buf.String() + + expected := `describe (-f FILENAME | TYPE [NAME_PREFIX | -l label] | TYPE/NAME)` + if !strings.Contains(got, expected) { + t.Errorf("Expected to contain: \n %v\nGot:\n %v\n", expected, got) + } + + unexpected := `describe (-f FILENAME | TYPE [NAME_PREFIX | -l label] | TYPE/NAME) [flags]` + if strings.Contains(got, unexpected) { + t.Errorf("Expected not to contain: \n %v\nGot:\n %v\n", unexpected, got) + } +} + +type testDescriber struct { + Name, Namespace string + Settings describe.DescriberSettings + Output string + Err error +} + +func (t *testDescriber) Describe(namespace, name string, describerSettings describe.DescriberSettings) (output string, err error) { + t.Namespace, t.Name = namespace, name + t.Settings = describerSettings + return t.Output, t.Err +} +func (t *testDescriber) describerFor(restClientGetter genericclioptions.RESTClientGetter, mapping *meta.RESTMapping) (describe.Describer, error) { + return t, nil +} diff --git a/pkg/cmd/diff/diff.go b/pkg/cmd/diff/diff.go new file mode 100644 index 000000000..faaa735f3 --- /dev/null +++ b/pkg/cmd/diff/diff.go @@ -0,0 +1,509 @@ +/* +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 diff + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + + "github.com/jonboulle/clockwork" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/klog" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/openapi" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubernetes/pkg/kubectl/cmd/apply" + "k8s.io/utils/exec" + "sigs.k8s.io/yaml" +) + +var ( + diffLong = templates.LongDesc(i18n.T(` + Diff configurations specified by filename or stdin between the current online + configuration, and the configuration as it would be if applied. + + Output is always YAML. + + KUBECTL_EXTERNAL_DIFF environment variable can be used to select your own + diff command. By default, the "diff" command available in your path will be + run with "-u" (unicode) and "-N" (treat new files as empty) options.`)) + diffExample = templates.Examples(i18n.T(` + # Diff resources included in pod.json. + kubectl diff -f pod.json + + # Diff file read from stdin + cat service.yaml | kubectl diff -f -`)) +) + +// Number of times we try to diff before giving-up +const maxRetries = 4 + +type DiffOptions struct { + FilenameOptions resource.FilenameOptions + + ServerSideApply bool + ForceConflicts bool + + OpenAPISchema openapi.Resources + DiscoveryClient discovery.DiscoveryInterface + DynamicClient dynamic.Interface + DryRunVerifier *apply.DryRunVerifier + CmdNamespace string + EnforceNamespace bool + Builder *resource.Builder + Diff *DiffProgram +} + +func validateArgs(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args) + } + return nil +} + +func NewDiffOptions(ioStreams genericclioptions.IOStreams) *DiffOptions { + return &DiffOptions{ + Diff: &DiffProgram{ + Exec: exec.New(), + IOStreams: ioStreams, + }, + } +} + +func NewCmdDiff(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + options := NewDiffOptions(streams) + cmd := &cobra.Command{ + Use: "diff -f FILENAME", + DisableFlagsInUseLine: true, + Short: i18n.T("Diff live version against would-be applied version"), + Long: diffLong, + Example: diffExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd)) + cmdutil.CheckErr(validateArgs(cmd, args)) + cmdutil.CheckErr(options.Run()) + }, + } + + usage := "contains the configuration to diff" + cmdutil.AddFilenameOptionFlags(cmd, &options.FilenameOptions, usage) + cmdutil.AddServerSideApplyFlags(cmd) + + return cmd +} + +// DiffProgram finds and run the diff program. The value of +// KUBECTL_EXTERNAL_DIFF environment variable will be used a diff +// program. By default, `diff(1)` will be used. +type DiffProgram struct { + Exec exec.Interface + genericclioptions.IOStreams +} + +func (d *DiffProgram) getCommand(args ...string) exec.Cmd { + diff := "" + if envDiff := os.Getenv("KUBECTL_EXTERNAL_DIFF"); envDiff != "" { + diff = envDiff + } else { + diff = "diff" + args = append([]string{"-u", "-N"}, args...) + } + + cmd := d.Exec.Command(diff, args...) + cmd.SetStdout(d.Out) + cmd.SetStderr(d.ErrOut) + + return cmd +} + +// Run runs the detected diff program. `from` and `to` are the directory to diff. +func (d *DiffProgram) Run(from, to string) error { + return d.getCommand(from, to).Run() +} + +// Printer is used to print an object. +type Printer struct{} + +// Print the object inside the writer w. +func (p *Printer) Print(obj runtime.Object, w io.Writer) error { + if obj == nil { + return nil + } + data, err := yaml.Marshal(obj) + if err != nil { + return err + } + _, err = w.Write(data) + return err + +} + +// DiffVersion gets the proper version of objects, and aggregate them into a directory. +type DiffVersion struct { + Dir *Directory + Name string +} + +// NewDiffVersion creates a new DiffVersion with the named version. +func NewDiffVersion(name string) (*DiffVersion, error) { + dir, err := CreateDirectory(name) + if err != nil { + return nil, err + } + return &DiffVersion{ + Dir: dir, + Name: name, + }, nil +} + +func (v *DiffVersion) getObject(obj Object) (runtime.Object, error) { + switch v.Name { + case "LIVE": + return obj.Live(), nil + case "MERGED": + return obj.Merged() + } + return nil, fmt.Errorf("Unknown version: %v", v.Name) +} + +// Print prints the object using the printer into a new file in the directory. +func (v *DiffVersion) Print(obj Object, printer Printer) error { + vobj, err := v.getObject(obj) + if err != nil { + return err + } + f, err := v.Dir.NewFile(obj.Name()) + if err != nil { + return err + } + defer f.Close() + return printer.Print(vobj, f) +} + +// Directory creates a new temp directory, and allows to easily create new files. +type Directory struct { + Name string +} + +// CreateDirectory does create the actual disk directory, and return a +// new representation of it. +func CreateDirectory(prefix string) (*Directory, error) { + name, err := ioutil.TempDir("", prefix+"-") + if err != nil { + return nil, err + } + + return &Directory{ + Name: name, + }, nil +} + +// NewFile creates a new file in the directory. +func (d *Directory) NewFile(name string) (*os.File, error) { + return os.OpenFile(filepath.Join(d.Name, name), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0700) +} + +// Delete removes the directory recursively. +func (d *Directory) Delete() error { + return os.RemoveAll(d.Name) +} + +// Object is an interface that let's you retrieve multiple version of +// it. +type Object interface { + Live() runtime.Object + Merged() (runtime.Object, error) + + Name() string +} + +// InfoObject is an implementation of the Object interface. It gets all +// the information from the Info object. +type InfoObject struct { + LocalObj runtime.Object + Info *resource.Info + Encoder runtime.Encoder + OpenAPI openapi.Resources + Force bool + ServerSideApply bool + ForceConflicts bool +} + +var _ Object = &InfoObject{} + +// Returns the live version of the object +func (obj InfoObject) Live() runtime.Object { + return obj.Info.Object +} + +// Returns the "merged" object, as it would look like if applied or +// created. +func (obj InfoObject) Merged() (runtime.Object, error) { + if obj.ServerSideApply { + data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj.LocalObj) + if err != nil { + return nil, err + } + options := metav1.PatchOptions{ + Force: &obj.ForceConflicts, + DryRun: []string{metav1.DryRunAll}, + } + return resource.NewHelper(obj.Info.Client, obj.Info.Mapping).Patch( + obj.Info.Namespace, + obj.Info.Name, + types.ApplyPatchType, + data, + &options, + ) + } + + // Build the patcher, and then apply the patch with dry-run, unless the object doesn't exist, in which case we need to create it. + if obj.Live() == nil { + // Dry-run create if the object doesn't exist. + return resource.NewHelper(obj.Info.Client, obj.Info.Mapping).Create( + obj.Info.Namespace, + true, + obj.LocalObj, + &metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}}, + ) + } + + var resourceVersion *string + if !obj.Force { + accessor, err := meta.Accessor(obj.Info.Object) + if err != nil { + return nil, err + } + str := accessor.GetResourceVersion() + resourceVersion = &str + } + + modified, err := util.GetModifiedConfiguration(obj.LocalObj, false, unstructured.UnstructuredJSONScheme) + if err != nil { + return nil, err + } + + // This is using the patcher from apply, to keep the same behavior. + // We plan on replacing this with server-side apply when it becomes available. + patcher := &apply.Patcher{ + Mapping: obj.Info.Mapping, + Helper: resource.NewHelper(obj.Info.Client, obj.Info.Mapping), + Overwrite: true, + BackOff: clockwork.NewRealClock(), + ServerDryRun: true, + OpenapiSchema: obj.OpenAPI, + ResourceVersion: resourceVersion, + } + + _, result, err := patcher.Patch(obj.Info.Object, modified, obj.Info.Source, obj.Info.Namespace, obj.Info.Name, nil) + return result, err +} + +func (obj InfoObject) Name() string { + group := "" + if obj.Info.Mapping.GroupVersionKind.Group != "" { + group = fmt.Sprintf("%v.", obj.Info.Mapping.GroupVersionKind.Group) + } + return group + fmt.Sprintf( + "%v.%v.%v.%v", + obj.Info.Mapping.GroupVersionKind.Version, + obj.Info.Mapping.GroupVersionKind.Kind, + obj.Info.Namespace, + obj.Info.Name, + ) +} + +// Differ creates two DiffVersion and diffs them. +type Differ struct { + From *DiffVersion + To *DiffVersion +} + +func NewDiffer(from, to string) (*Differ, error) { + differ := Differ{} + var err error + differ.From, err = NewDiffVersion(from) + if err != nil { + return nil, err + } + differ.To, err = NewDiffVersion(to) + if err != nil { + differ.From.Dir.Delete() + return nil, err + } + + return &differ, nil +} + +// Diff diffs to versions of a specific object, and print both versions to directories. +func (d *Differ) Diff(obj Object, printer Printer) error { + if err := d.From.Print(obj, printer); err != nil { + return err + } + if err := d.To.Print(obj, printer); err != nil { + return err + } + return nil +} + +// Run runs the diff program against both directories. +func (d *Differ) Run(diff *DiffProgram) error { + return diff.Run(d.From.Dir.Name, d.To.Dir.Name) +} + +// TearDown removes both temporary directories recursively. +func (d *Differ) TearDown() { + d.From.Dir.Delete() // Ignore error + d.To.Dir.Delete() // Ignore error +} + +func isConflict(err error) bool { + return err != nil && errors.IsConflict(err) +} + +func (o *DiffOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + var err error + + err = o.FilenameOptions.RequireFilenameOrKustomize() + if err != nil { + return err + } + + o.ServerSideApply = cmdutil.GetServerSideApplyFlag(cmd) + o.ForceConflicts = cmdutil.GetForceConflictsFlag(cmd) + if o.ForceConflicts && !o.ServerSideApply { + return fmt.Errorf("--experimental-force-conflicts only works with --experimental-server-side") + } + + if !o.ServerSideApply { + o.OpenAPISchema, err = f.OpenAPISchema() + if err != nil { + return err + } + } + + o.DiscoveryClient, err = f.ToDiscoveryClient() + if err != nil { + return err + } + + o.DynamicClient, err = f.DynamicClient() + if err != nil { + return err + } + + o.DryRunVerifier = &apply.DryRunVerifier{ + Finder: cmdutil.NewCRDFinder(cmdutil.CRDFromDynamic(o.DynamicClient)), + OpenAPIGetter: o.DiscoveryClient, + } + + o.CmdNamespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.Builder = f.NewBuilder() + return nil +} + +// RunDiff uses the factory to parse file arguments, find the version to +// diff, and find each Info object for each files, and runs against the +// differ. +func (o *DiffOptions) Run() error { + differ, err := NewDiffer("LIVE", "MERGED") + if err != nil { + return err + } + defer differ.TearDown() + + printer := Printer{} + + r := o.Builder. + Unstructured(). + NamespaceParam(o.CmdNamespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.FilenameOptions). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + err = r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + if err := o.DryRunVerifier.HasSupport(info.Mapping.GroupVersionKind); err != nil { + return err + } + + local := info.Object.DeepCopyObject() + for i := 1; i <= maxRetries; i++ { + if err = info.Get(); err != nil { + if !errors.IsNotFound(err) { + return err + } + info.Object = nil + } + + force := i == maxRetries + if force { + klog.Warningf( + "Object (%v: %v) keeps changing, diffing without lock", + info.Object.GetObjectKind().GroupVersionKind(), + info.Name, + ) + } + obj := InfoObject{ + LocalObj: local, + Info: info, + Encoder: scheme.DefaultJSONEncoder(), + OpenAPI: o.OpenAPISchema, + Force: force, + ServerSideApply: o.ServerSideApply, + ForceConflicts: o.ForceConflicts, + } + + err = differ.Diff(obj, printer) + if !isConflict(err) { + break + } + } + return err + }) + if err != nil { + return err + } + + return differ.Run(o.Diff) +} diff --git a/pkg/cmd/diff/diff_test.go b/pkg/cmd/diff/diff_test.go new file mode 100644 index 000000000..2b833e551 --- /dev/null +++ b/pkg/cmd/diff/diff_test.go @@ -0,0 +1,196 @@ +/* +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 diff + +import ( + "bytes" + "io/ioutil" + "os" + "path" + "path/filepath" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/utils/exec" +) + +type FakeObject struct { + name string + merged map[string]interface{} + live map[string]interface{} +} + +var _ Object = &FakeObject{} + +func (f *FakeObject) Name() string { + return f.name +} + +func (f *FakeObject) Merged() (runtime.Object, error) { + return &unstructured.Unstructured{Object: f.merged}, nil +} + +func (f *FakeObject) Live() runtime.Object { + return &unstructured.Unstructured{Object: f.live} +} + +func TestDiffProgram(t *testing.T) { + os.Setenv("KUBECTL_EXTERNAL_DIFF", "echo") + streams, _, stdout, _ := genericclioptions.NewTestIOStreams() + diff := DiffProgram{ + IOStreams: streams, + Exec: exec.New(), + } + err := diff.Run("one", "two") + if err != nil { + t.Fatal(err) + } + if output := stdout.String(); output != "one two\n" { + t.Fatalf(`stdout = %q, expected "one two\n"`, output) + } +} + +func TestPrinter(t *testing.T) { + printer := Printer{} + + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "string": "string", + "list": []int{1, 2, 3}, + "int": 12, + }} + buf := bytes.Buffer{} + printer.Print(obj, &buf) + want := `int: 12 +list: +- 1 +- 2 +- 3 +string: string +` + if buf.String() != want { + t.Errorf("Print() = %q, want %q", buf.String(), want) + } +} + +func TestDiffVersion(t *testing.T) { + diff, err := NewDiffVersion("MERGED") + if err != nil { + t.Fatal(err) + } + defer diff.Dir.Delete() + + obj := FakeObject{ + name: "bla", + live: map[string]interface{}{"live": true}, + merged: map[string]interface{}{"merged": true}, + } + err = diff.Print(&obj, Printer{}) + if err != nil { + t.Fatal(err) + } + fcontent, err := ioutil.ReadFile(path.Join(diff.Dir.Name, obj.Name())) + if err != nil { + t.Fatal(err) + } + econtent := "merged: true\n" + if string(fcontent) != econtent { + t.Fatalf("File has %q, expected %q", string(fcontent), econtent) + } +} + +func TestDirectory(t *testing.T) { + dir, err := CreateDirectory("prefix") + defer dir.Delete() + if err != nil { + t.Fatal(err) + } + _, err = os.Stat(dir.Name) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(filepath.Base(dir.Name), "prefix") { + t.Fatalf(`Directory doesn't start with "prefix": %q`, dir.Name) + } + entries, err := ioutil.ReadDir(dir.Name) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("Directory should be empty, has %d elements", len(entries)) + } + _, err = dir.NewFile("ONE") + if err != nil { + t.Fatal(err) + } + _, err = dir.NewFile("TWO") + if err != nil { + t.Fatal(err) + } + entries, err = ioutil.ReadDir(dir.Name) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("ReadDir should have two elements, has %d elements", len(entries)) + } + err = dir.Delete() + if err != nil { + t.Fatal(err) + } + _, err = os.Stat(dir.Name) + if err == nil { + t.Fatal("Directory should be gone, still present.") + } +} + +func TestDiffer(t *testing.T) { + diff, err := NewDiffer("LIVE", "MERGED") + if err != nil { + t.Fatal(err) + } + defer diff.TearDown() + + obj := FakeObject{ + name: "bla", + live: map[string]interface{}{"live": true}, + merged: map[string]interface{}{"merged": true}, + } + err = diff.Diff(&obj, Printer{}) + if err != nil { + t.Fatal(err) + } + fcontent, err := ioutil.ReadFile(path.Join(diff.From.Dir.Name, obj.Name())) + if err != nil { + t.Fatal(err) + } + econtent := "live: true\n" + if string(fcontent) != econtent { + t.Fatalf("File has %q, expected %q", string(fcontent), econtent) + } + + fcontent, err = ioutil.ReadFile(path.Join(diff.To.Dir.Name, obj.Name())) + if err != nil { + t.Fatal(err) + } + econtent = "merged: true\n" + if string(fcontent) != econtent { + t.Fatalf("File has %q, expected %q", string(fcontent), econtent) + } +} diff --git a/pkg/cmd/drain/drain.go b/pkg/cmd/drain/drain.go new file mode 100644 index 000000000..985d6553d --- /dev/null +++ b/pkg/cmd/drain/drain.go @@ -0,0 +1,535 @@ +/* +Copyright 2015 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 drain + +import ( + "errors" + "fmt" + "math" + "time" + + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/wait" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/drain" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +type DrainCmdOptions struct { + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinterFunc, error) + + Namespace string + + drainer *drain.Helper + nodeInfos []*resource.Info + + genericclioptions.IOStreams +} + +var ( + cordonLong = templates.LongDesc(i18n.T(` + Mark node as unschedulable.`)) + + cordonExample = templates.Examples(i18n.T(` + # Mark node "foo" as unschedulable. + kubectl cordon foo`)) +) + +func NewCmdCordon(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewDrainCmdOptions(f, ioStreams) + + cmd := &cobra.Command{ + Use: "cordon NODE", + DisableFlagsInUseLine: true, + Short: i18n.T("Mark node as unschedulable"), + Long: cordonLong, + Example: cordonExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.RunCordonOrUncordon(true)) + }, + } + cmd.Flags().StringVarP(&o.drainer.Selector, "selector", "l", o.drainer.Selector, "Selector (label query) to filter on") + cmdutil.AddDryRunFlag(cmd) + return cmd +} + +var ( + uncordonLong = templates.LongDesc(i18n.T(` + Mark node as schedulable.`)) + + uncordonExample = templates.Examples(i18n.T(` + # Mark node "foo" as schedulable. + $ kubectl uncordon foo`)) +) + +func NewCmdUncordon(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewDrainCmdOptions(f, ioStreams) + + cmd := &cobra.Command{ + Use: "uncordon NODE", + DisableFlagsInUseLine: true, + Short: i18n.T("Mark node as schedulable"), + Long: uncordonLong, + Example: uncordonExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.RunCordonOrUncordon(false)) + }, + } + cmd.Flags().StringVarP(&o.drainer.Selector, "selector", "l", o.drainer.Selector, "Selector (label query) to filter on") + cmdutil.AddDryRunFlag(cmd) + return cmd +} + +var ( + drainLong = templates.LongDesc(i18n.T(` + Drain node in preparation for maintenance. + + The given node will be marked unschedulable to prevent new pods from arriving. + 'drain' evicts the pods if the APIServer supports + [eviction](http://kubernetes.io/docs/admin/disruptions/). Otherwise, it will use normal + DELETE to delete the pods. + The 'drain' evicts or deletes all pods except mirror pods (which cannot be deleted through + the API server). If there are DaemonSet-managed pods, drain will not proceed + without --ignore-daemonsets, and regardless it will not delete any + DaemonSet-managed pods, because those pods would be immediately replaced by the + DaemonSet controller, which ignores unschedulable markings. If there are any + pods that are neither mirror pods nor managed by ReplicationController, + ReplicaSet, DaemonSet, StatefulSet or Job, then drain will not delete any pods unless you + use --force. --force will also allow deletion to proceed if the managing resource of one + or more pods is missing. + + 'drain' waits for graceful termination. You should not operate on the machine until + the command completes. + + When you are ready to put the node back into service, use kubectl uncordon, which + will make the node schedulable again. + + ![Workflow](http://kubernetes.io/images/docs/kubectl_drain.svg)`)) + + drainExample = templates.Examples(i18n.T(` + # Drain node "foo", even if there are pods not managed by a ReplicationController, ReplicaSet, Job, DaemonSet or StatefulSet on it. + $ kubectl drain foo --force + + # As above, but abort if there are pods not managed by a ReplicationController, ReplicaSet, Job, DaemonSet or StatefulSet, and use a grace period of 15 minutes. + $ kubectl drain foo --grace-period=900`)) +) + +func NewDrainCmdOptions(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *DrainCmdOptions { + return &DrainCmdOptions{ + PrintFlags: genericclioptions.NewPrintFlags("drained").WithTypeSetter(scheme.Scheme), + IOStreams: ioStreams, + drainer: &drain.Helper{ + GracePeriodSeconds: -1, + ErrOut: ioStreams.ErrOut, + }, + } +} + +func NewCmdDrain(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewDrainCmdOptions(f, ioStreams) + + cmd := &cobra.Command{ + Use: "drain NODE", + DisableFlagsInUseLine: true, + Short: i18n.T("Drain node in preparation for maintenance"), + Long: drainLong, + Example: drainExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.RunDrain()) + }, + } + cmd.Flags().BoolVar(&o.drainer.Force, "force", o.drainer.Force, "Continue even if there are pods not managed by a ReplicationController, ReplicaSet, Job, DaemonSet or StatefulSet.") + cmd.Flags().BoolVar(&o.drainer.IgnoreAllDaemonSets, "ignore-daemonsets", o.drainer.IgnoreAllDaemonSets, "Ignore DaemonSet-managed pods.") + cmd.Flags().BoolVar(&o.drainer.DeleteLocalData, "delete-local-data", o.drainer.DeleteLocalData, "Continue even if there are pods using emptyDir (local data that will be deleted when the node is drained).") + cmd.Flags().IntVar(&o.drainer.GracePeriodSeconds, "grace-period", o.drainer.GracePeriodSeconds, "Period of time in seconds given to each pod to terminate gracefully. If negative, the default value specified in the pod will be used.") + cmd.Flags().DurationVar(&o.drainer.Timeout, "timeout", o.drainer.Timeout, "The length of time to wait before giving up, zero means infinite") + cmd.Flags().StringVarP(&o.drainer.Selector, "selector", "l", o.drainer.Selector, "Selector (label query) to filter on") + cmd.Flags().StringVarP(&o.drainer.PodSelector, "pod-selector", "", o.drainer.PodSelector, "Label selector to filter pods on the node") + + cmdutil.AddDryRunFlag(cmd) + return cmd +} + +// Complete populates some fields from the factory, grabs command line +// arguments and looks up the node using Builder +func (o *DrainCmdOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + + if len(args) == 0 && !cmd.Flags().Changed("selector") { + return cmdutil.UsageErrorf(cmd, fmt.Sprintf("USAGE: %s [flags]", cmd.Use)) + } + if len(args) > 0 && len(o.drainer.Selector) > 0 { + return cmdutil.UsageErrorf(cmd, "error: cannot specify both a node name and a --selector option") + } + + o.drainer.DryRun = cmdutil.GetDryRunFlag(cmd) + + if o.drainer.Client, err = f.KubernetesClientSet(); err != nil { + return err + } + + if len(o.drainer.PodSelector) > 0 { + if _, err := labels.Parse(o.drainer.PodSelector); err != nil { + return errors.New("--pod-selector= must be a valid label selector") + } + } + + o.nodeInfos = []*resource.Info{} + + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.ToPrinter = func(operation string) (printers.ResourcePrinterFunc, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + if o.drainer.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return nil, err + } + + return printer.PrintObj, nil + } + + builder := f.NewBuilder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace(). + ResourceNames("nodes", args...). + SingleResourceType(). + Flatten() + + if len(o.drainer.Selector) > 0 { + builder = builder.LabelSelectorParam(o.drainer.Selector). + ResourceTypes("nodes") + } + + r := builder.Do() + + if err = r.Err(); err != nil { + return err + } + + return r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + if info.Mapping.Resource.GroupResource() != (schema.GroupResource{Group: "", Resource: "nodes"}) { + return fmt.Errorf("error: expected resource of type node, got %q", info.Mapping.Resource) + } + + o.nodeInfos = append(o.nodeInfos, info) + return nil + }) +} + +// RunDrain runs the 'drain' command +func (o *DrainCmdOptions) RunDrain() error { + if err := o.RunCordonOrUncordon(true); err != nil { + return err + } + + printObj, err := o.ToPrinter("drained") + if err != nil { + return err + } + + drainedNodes := sets.NewString() + var fatal error + + for _, info := range o.nodeInfos { + var err error + if !o.drainer.DryRun { + err = o.deleteOrEvictPodsSimple(info) + } + if err == nil || o.drainer.DryRun { + drainedNodes.Insert(info.Name) + printObj(info.Object, o.Out) + } else { + fmt.Fprintf(o.ErrOut, "error: unable to drain node %q, aborting command...\n\n", info.Name) + remainingNodes := []string{} + fatal = err + for _, remainingInfo := range o.nodeInfos { + if drainedNodes.Has(remainingInfo.Name) { + continue + } + remainingNodes = append(remainingNodes, remainingInfo.Name) + } + + if len(remainingNodes) > 0 { + fmt.Fprintf(o.ErrOut, "There are pending nodes to be drained:\n") + for _, nodeName := range remainingNodes { + fmt.Fprintf(o.ErrOut, " %s\n", nodeName) + } + } + break + } + } + + return fatal +} + +func (o *DrainCmdOptions) deleteOrEvictPodsSimple(nodeInfo *resource.Info) error { + list, errs := o.drainer.GetPodsForDeletion(nodeInfo.Name) + if errs != nil { + return utilerrors.NewAggregate(errs) + } + if warnings := list.Warnings(); warnings != "" { + fmt.Fprintf(o.ErrOut, "WARNING: %s\n", warnings) + } + + if err := o.deleteOrEvictPods(list.Pods()); err != nil { + pendingList, newErrs := o.drainer.GetPodsForDeletion(nodeInfo.Name) + + fmt.Fprintf(o.ErrOut, "There are pending pods in node %q when an error occurred: %v\n", nodeInfo.Name, err) + for _, pendingPod := range pendingList.Pods() { + fmt.Fprintf(o.ErrOut, "%s/%s\n", "pod", pendingPod.Name) + } + if newErrs != nil { + fmt.Fprintf(o.ErrOut, "following errors also occurred:\n%s", utilerrors.NewAggregate(newErrs)) + } + return err + } + return nil +} + +// deleteOrEvictPods deletes or evicts the pods on the api server +func (o *DrainCmdOptions) deleteOrEvictPods(pods []corev1.Pod) error { + if len(pods) == 0 { + return nil + } + + policyGroupVersion, err := drain.CheckEvictionSupport(o.drainer.Client) + if err != nil { + return err + } + + getPodFn := func(namespace, name string) (*corev1.Pod, error) { + return o.drainer.Client.CoreV1().Pods(namespace).Get(name, metav1.GetOptions{}) + } + + if len(policyGroupVersion) > 0 { + return o.evictPods(pods, policyGroupVersion, getPodFn) + } else { + return o.deletePods(pods, getPodFn) + } +} + +func (o *DrainCmdOptions) evictPods(pods []corev1.Pod, policyGroupVersion string, getPodFn func(namespace, name string) (*corev1.Pod, error)) error { + returnCh := make(chan error, 1) + + for _, pod := range pods { + go func(pod corev1.Pod, returnCh chan error) { + for { + fmt.Fprintf(o.Out, "evicting pod %q\n", pod.Name) + err := o.drainer.EvictPod(pod, policyGroupVersion) + if err == nil { + break + } else if apierrors.IsNotFound(err) { + returnCh <- nil + return + } else if apierrors.IsTooManyRequests(err) { + fmt.Fprintf(o.ErrOut, "error when evicting pod %q (will retry after 5s): %v\n", pod.Name, err) + time.Sleep(5 * time.Second) + } else { + returnCh <- fmt.Errorf("error when evicting pod %q: %v", pod.Name, err) + return + } + } + _, err := o.waitForDelete([]corev1.Pod{pod}, 1*time.Second, time.Duration(math.MaxInt64), true, getPodFn) + if err == nil { + returnCh <- nil + } else { + returnCh <- fmt.Errorf("error when waiting for pod %q terminating: %v", pod.Name, err) + } + }(pod, returnCh) + } + + doneCount := 0 + var errors []error + + // 0 timeout means infinite, we use MaxInt64 to represent it. + var globalTimeout time.Duration + if o.drainer.Timeout == 0 { + globalTimeout = time.Duration(math.MaxInt64) + } else { + globalTimeout = o.drainer.Timeout + } + globalTimeoutCh := time.After(globalTimeout) + numPods := len(pods) + for doneCount < numPods { + select { + case err := <-returnCh: + doneCount++ + if err != nil { + errors = append(errors, err) + } + case <-globalTimeoutCh: + return fmt.Errorf("drain did not complete within %v", globalTimeout) + } + } + return utilerrors.NewAggregate(errors) +} + +func (o *DrainCmdOptions) deletePods(pods []corev1.Pod, getPodFn func(namespace, name string) (*corev1.Pod, error)) error { + // 0 timeout means infinite, we use MaxInt64 to represent it. + var globalTimeout time.Duration + if o.drainer.Timeout == 0 { + globalTimeout = time.Duration(math.MaxInt64) + } else { + globalTimeout = o.drainer.Timeout + } + for _, pod := range pods { + err := o.drainer.DeletePod(pod) + if err != nil && !apierrors.IsNotFound(err) { + return err + } + } + _, err := o.waitForDelete(pods, 1*time.Second, globalTimeout, false, getPodFn) + return err +} + +func (o *DrainCmdOptions) waitForDelete(pods []corev1.Pod, interval, timeout time.Duration, usingEviction bool, getPodFn func(string, string) (*corev1.Pod, error)) ([]corev1.Pod, error) { + var verbStr string + if usingEviction { + verbStr = "evicted" + } else { + verbStr = "deleted" + } + printObj, err := o.ToPrinter(verbStr) + if err != nil { + return pods, err + } + + err = wait.PollImmediate(interval, timeout, func() (bool, error) { + pendingPods := []corev1.Pod{} + for i, pod := range pods { + p, err := getPodFn(pod.Namespace, pod.Name) + if apierrors.IsNotFound(err) || (p != nil && p.ObjectMeta.UID != pod.ObjectMeta.UID) { + printObj(&pod, o.Out) + continue + } else if err != nil { + return false, err + } else { + pendingPods = append(pendingPods, pods[i]) + } + } + pods = pendingPods + if len(pendingPods) > 0 { + return false, nil + } + return true, nil + }) + return pods, err +} + +// RunCordonOrUncordon runs either Cordon or Uncordon. The desired value for +// "Unschedulable" is passed as the first arg. +func (o *DrainCmdOptions) RunCordonOrUncordon(desired bool) error { + cordonOrUncordon := "cordon" + if !desired { + cordonOrUncordon = "un" + cordonOrUncordon + } + + for _, nodeInfo := range o.nodeInfos { + + printError := func(err error) { + fmt.Fprintf(o.ErrOut, "error: unable to %s node %q: %v\n", cordonOrUncordon, nodeInfo.Name, err) + } + + gvk := nodeInfo.ResourceMapping().GroupVersionKind + if gvk.Kind == "Node" { + c, err := drain.NewCordonHelperFromRuntimeObject(nodeInfo.Object, scheme.Scheme, gvk) + if err != nil { + printError(err) + continue + } + + if updateRequired := c.UpdateIfRequired(desired); !updateRequired { + printObj, err := o.ToPrinter(already(desired)) + if err != nil { + fmt.Fprintf(o.ErrOut, "error: %v\n", err) + continue + } + printObj(nodeInfo.Object, o.Out) + } else { + if !o.drainer.DryRun { + err, patchErr := c.PatchOrReplace(o.drainer.Client) + if patchErr != nil { + printError(patchErr) + } + if err != nil { + printError(err) + continue + } + } + printObj, err := o.ToPrinter(changed(desired)) + if err != nil { + fmt.Fprintf(o.ErrOut, "%v\n", err) + continue + } + printObj(nodeInfo.Object, o.Out) + } + } else { + printObj, err := o.ToPrinter("skipped") + if err != nil { + fmt.Fprintf(o.ErrOut, "%v\n", err) + continue + } + printObj(nodeInfo.Object, o.Out) + } + } + + return nil +} + +// already() and changed() return suitable strings for {un,}cordoning + +func already(desired bool) string { + if desired { + return "already cordoned" + } + return "already uncordoned" +} + +func changed(desired bool) string { + if desired { + return "cordoned" + } + return "uncordoned" +} diff --git a/pkg/cmd/drain/drain_test.go b/pkg/cmd/drain/drain_test.go new file mode 100644 index 000000000..a910aaba5 --- /dev/null +++ b/pkg/cmd/drain/drain_test.go @@ -0,0 +1,1050 @@ +/* +Copyright 2015 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 drain + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "reflect" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + policyv1beta1 "k8s.io/api/policy/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/client-go/rest/fake" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/drain" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" + utilpointer "k8s.io/utils/pointer" +) + +const ( + EvictionMethod = "Eviction" + DeleteMethod = "Delete" +) + +var node *corev1.Node +var cordonedNode *corev1.Node + +func TestMain(m *testing.M) { + // Create a node. + node = &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node", + CreationTimestamp: metav1.Time{Time: time.Now()}, + }, + Status: corev1.NodeStatus{}, + } + + // A copy of the same node, but cordoned. + cordonedNode = node.DeepCopy() + cordonedNode.Spec.Unschedulable = true + os.Exit(m.Run()) +} + +func TestCordon(t *testing.T) { + tests := []struct { + description string + node *corev1.Node + expected *corev1.Node + cmd func(cmdutil.Factory, genericclioptions.IOStreams) *cobra.Command + arg string + expectFatal bool + }{ + { + description: "node/node syntax", + node: cordonedNode, + expected: node, + cmd: NewCmdUncordon, + arg: "node/node", + expectFatal: false, + }, + { + description: "uncordon for real", + node: cordonedNode, + expected: node, + cmd: NewCmdUncordon, + arg: "node", + expectFatal: false, + }, + { + description: "uncordon does nothing", + node: node, + expected: node, + cmd: NewCmdUncordon, + arg: "node", + expectFatal: false, + }, + { + description: "cordon does nothing", + node: cordonedNode, + expected: cordonedNode, + cmd: NewCmdCordon, + arg: "node", + expectFatal: false, + }, + { + description: "cordon for real", + node: node, + expected: cordonedNode, + cmd: NewCmdCordon, + arg: "node", + expectFatal: false, + }, + { + description: "cordon missing node", + node: node, + expected: node, + cmd: NewCmdCordon, + arg: "bar", + expectFatal: true, + }, + { + description: "uncordon missing node", + node: node, + expected: node, + cmd: NewCmdUncordon, + arg: "bar", + expectFatal: true, + }, + { + description: "cordon for multiple nodes", + node: node, + expected: cordonedNode, + cmd: NewCmdCordon, + arg: "node node1 node2", + expectFatal: false, + }, + { + description: "uncordon for multiple nodes", + node: cordonedNode, + expected: node, + cmd: NewCmdUncordon, + arg: "node node1 node2", + expectFatal: false, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + tf := cmdtesting.NewTestFactory() + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + newNode := &corev1.Node{} + updated := false + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + m := &MyReq{req} + switch { + case m.isFor("GET", "/nodes/node1"): + fallthrough + case m.isFor("GET", "/nodes/node2"): + fallthrough + case m.isFor("GET", "/nodes/node"): + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, test.node)}, nil + case m.isFor("GET", "/nodes/bar"): + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("nope")}, nil + case m.isFor("PATCH", "/nodes/node1"): + fallthrough + case m.isFor("PATCH", "/nodes/node2"): + fallthrough + case m.isFor("PATCH", "/nodes/node"): + data, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + defer req.Body.Close() + oldJSON, err := runtime.Encode(codec, node) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + appliedPatch, err := strategicpatch.StrategicMergePatch(oldJSON, data, &corev1.Node{}) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + if err := runtime.DecodeInto(codec, appliedPatch, newNode); err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + if !reflect.DeepEqual(test.expected.Spec, newNode.Spec) { + t.Fatalf("%s: expected:\n%v\nsaw:\n%v\n", test.description, test.expected.Spec.Unschedulable, newNode.Spec.Unschedulable) + } + updated = true + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, newNode)}, nil + default: + t.Fatalf("%s: unexpected request: %v %#v\n%#v", test.description, req.Method, req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + cmd := test.cmd(tf, ioStreams) + + sawFatal := false + func() { + defer func() { + // Recover from the panic below. + _ = recover() + // Restore cmdutil behavior + cmdutil.DefaultBehaviorOnFatal() + }() + cmdutil.BehaviorOnFatal(func(e string, code int) { + sawFatal = true + panic(e) + }) + cmd.SetArgs(strings.Split(test.arg, " ")) + cmd.Execute() + }() + + if test.expectFatal { + if !sawFatal { + t.Fatalf("%s: unexpected non-error", test.description) + } + if updated { + t.Fatalf("%s: unexpected update", test.description) + } + } + + if !test.expectFatal && sawFatal { + t.Fatalf("%s: unexpected error", test.description) + } + if !reflect.DeepEqual(test.expected.Spec, test.node.Spec) && !updated { + t.Fatalf("%s: node never updated", test.description) + } + }) + } +} + +func TestDrain(t *testing.T) { + labels := make(map[string]string) + labels["my_key"] = "my_value" + + rc := corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rc", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + }, + Spec: corev1.ReplicationControllerSpec{ + Selector: labels, + }, + } + + rcPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "ReplicationController", + Name: "rc", + UID: "123", + BlockOwnerDeletion: utilpointer.BoolPtr(true), + Controller: utilpointer.BoolPtr(true), + }, + }, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + }, + } + + ds := appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ds", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + }, + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: labels}, + }, + } + + dsPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "DaemonSet", + Name: "ds", + BlockOwnerDeletion: utilpointer.BoolPtr(true), + Controller: utilpointer.BoolPtr(true), + }, + }, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + }, + } + + dsTerminatedPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "DaemonSet", + Name: "ds", + BlockOwnerDeletion: utilpointer.BoolPtr(true), + Controller: utilpointer.BoolPtr(true), + }, + }, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + }, + Status: corev1.PodStatus{ + Phase: corev1.PodSucceeded, + }, + } + + dsPodWithEmptyDir := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "DaemonSet", + Name: "ds", + BlockOwnerDeletion: utilpointer.BoolPtr(true), + Controller: utilpointer.BoolPtr(true), + }, + }, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + Volumes: []corev1.Volume{ + { + Name: "scratch", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{Medium: ""}}, + }, + }, + }, + } + + orphanedDsPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + }, + } + + job := batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + }, + Spec: batchv1.JobSpec{ + Selector: &metav1.LabelSelector{MatchLabels: labels}, + }, + } + + jobPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Job", + Name: "job", + BlockOwnerDeletion: utilpointer.BoolPtr(true), + Controller: utilpointer.BoolPtr(true), + }, + }, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + Volumes: []corev1.Volume{ + { + Name: "scratch", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{Medium: ""}}, + }, + }, + }, + } + + terminatedJobPodWithLocalStorage := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Job", + Name: "job", + BlockOwnerDeletion: utilpointer.BoolPtr(true), + Controller: utilpointer.BoolPtr(true), + }, + }, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + Volumes: []corev1.Volume{ + { + Name: "scratch", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{Medium: ""}}, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodSucceeded, + }, + } + + rs := appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rs", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: labels}, + }, + } + + rsPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "ReplicaSet", + Name: "rs", + BlockOwnerDeletion: utilpointer.BoolPtr(true), + Controller: utilpointer.BoolPtr(true), + }, + }, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + }, + } + + nakedPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + }, + } + + emptydirPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + Volumes: []corev1.Volume{ + { + Name: "scratch", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{Medium: ""}}, + }, + }, + }, + } + emptydirTerminatedPod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bar", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + Labels: labels, + }, + Spec: corev1.PodSpec{ + NodeName: "node", + Volumes: []corev1.Volume{ + { + Name: "scratch", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{Medium: ""}}, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodFailed, + }, + } + + tests := []struct { + description string + node *corev1.Node + expected *corev1.Node + pods []corev1.Pod + rcs []corev1.ReplicationController + replicaSets []appsv1.ReplicaSet + args []string + expectWarning string + expectFatal bool + expectDelete bool + }{ + { + description: "RC-managed pod", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{rcPod}, + rcs: []corev1.ReplicationController{rc}, + args: []string{"node"}, + expectFatal: false, + expectDelete: true, + }, + { + description: "DS-managed pod", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{dsPod}, + rcs: []corev1.ReplicationController{rc}, + args: []string{"node"}, + expectFatal: true, + expectDelete: false, + }, + { + description: "DS-managed terminated pod", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{dsTerminatedPod}, + rcs: []corev1.ReplicationController{rc}, + args: []string{"node"}, + expectFatal: false, + expectDelete: true, + }, + { + description: "orphaned DS-managed pod", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{orphanedDsPod}, + rcs: []corev1.ReplicationController{}, + args: []string{"node"}, + expectFatal: true, + expectDelete: false, + }, + { + description: "orphaned DS-managed pod with --force", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{orphanedDsPod}, + rcs: []corev1.ReplicationController{}, + args: []string{"node", "--force"}, + expectFatal: false, + expectDelete: true, + expectWarning: "WARNING: deleting Pods not managed by ReplicationController, ReplicaSet, Job, DaemonSet or StatefulSet: default/bar", + }, + { + description: "DS-managed pod with --ignore-daemonsets", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{dsPod}, + rcs: []corev1.ReplicationController{rc}, + args: []string{"node", "--ignore-daemonsets"}, + expectFatal: false, + expectDelete: false, + }, + { + description: "DS-managed pod with emptyDir with --ignore-daemonsets", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{dsPodWithEmptyDir}, + rcs: []corev1.ReplicationController{rc}, + args: []string{"node", "--ignore-daemonsets"}, + expectWarning: "WARNING: ignoring DaemonSet-managed Pods: default/bar", + expectFatal: false, + expectDelete: false, + }, + { + description: "Job-managed pod with local storage", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{jobPod}, + rcs: []corev1.ReplicationController{rc}, + args: []string{"node", "--force", "--delete-local-data=true"}, + expectFatal: false, + expectDelete: true, + }, + { + description: "Job-managed terminated pod", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{terminatedJobPodWithLocalStorage}, + rcs: []corev1.ReplicationController{rc}, + args: []string{"node"}, + expectFatal: false, + expectDelete: true, + }, + { + description: "RS-managed pod", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{rsPod}, + replicaSets: []appsv1.ReplicaSet{rs}, + args: []string{"node"}, + expectFatal: false, + expectDelete: true, + }, + { + description: "naked pod", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{nakedPod}, + rcs: []corev1.ReplicationController{}, + args: []string{"node"}, + expectFatal: true, + expectDelete: false, + }, + { + description: "naked pod with --force", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{nakedPod}, + rcs: []corev1.ReplicationController{}, + args: []string{"node", "--force"}, + expectFatal: false, + expectDelete: true, + }, + { + description: "pod with EmptyDir", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{emptydirPod}, + args: []string{"node", "--force"}, + expectFatal: true, + expectDelete: false, + }, + { + description: "terminated pod with emptyDir", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{emptydirTerminatedPod}, + rcs: []corev1.ReplicationController{rc}, + args: []string{"node"}, + expectFatal: false, + expectDelete: true, + }, + { + description: "pod with EmptyDir and --delete-local-data", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{emptydirPod}, + args: []string{"node", "--force", "--delete-local-data=true"}, + expectFatal: false, + expectDelete: true, + }, + { + description: "empty node", + node: node, + expected: cordonedNode, + pods: []corev1.Pod{}, + rcs: []corev1.ReplicationController{rc}, + args: []string{"node"}, + expectFatal: false, + expectDelete: false, + }, + } + + testEviction := false + for i := 0; i < 2; i++ { + testEviction = !testEviction + var currMethod string + if testEviction { + currMethod = EvictionMethod + } else { + currMethod = DeleteMethod + } + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + newNode := &corev1.Node{} + var deletions, evictions int32 + tf := cmdtesting.NewTestFactory() + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + m := &MyReq{req} + switch { + case req.Method == "GET" && req.URL.Path == "/api": + apiVersions := metav1.APIVersions{ + Versions: []string{"v1"}, + } + return cmdtesting.GenResponseWithJsonEncodedBody(apiVersions) + case req.Method == "GET" && req.URL.Path == "/apis": + groupList := metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "policy", + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "policy/v1beta1", + }, + }, + }, + } + return cmdtesting.GenResponseWithJsonEncodedBody(groupList) + case req.Method == "GET" && req.URL.Path == "/api/v1": + resourceList := metav1.APIResourceList{ + GroupVersion: "v1", + } + if testEviction { + resourceList.APIResources = []metav1.APIResource{ + { + Name: drain.EvictionSubresource, + Kind: drain.EvictionKind, + }, + } + } + return cmdtesting.GenResponseWithJsonEncodedBody(resourceList) + case m.isFor("GET", "/nodes/node"): + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, test.node)}, nil + case m.isFor("GET", "/namespaces/default/replicationcontrollers/rc"): + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &test.rcs[0])}, nil + case m.isFor("GET", "/namespaces/default/daemonsets/ds"): + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &ds)}, nil + case m.isFor("GET", "/namespaces/default/daemonsets/missing-ds"): + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &appsv1.DaemonSet{})}, nil + case m.isFor("GET", "/namespaces/default/jobs/job"): + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &job)}, nil + case m.isFor("GET", "/namespaces/default/replicasets/rs"): + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &test.replicaSets[0])}, nil + case m.isFor("GET", "/namespaces/default/pods/bar"): + return &http.Response{StatusCode: 404, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.Pod{})}, nil + case m.isFor("GET", "/pods"): + values, err := url.ParseQuery(req.URL.RawQuery) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + getParams := make(url.Values) + getParams["fieldSelector"] = []string{"spec.nodeName=node"} + if !reflect.DeepEqual(getParams, values) { + t.Fatalf("%s: expected:\n%v\nsaw:\n%v\n", test.description, getParams, values) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.PodList{Items: test.pods})}, nil + case m.isFor("GET", "/replicationcontrollers"): + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.ReplicationControllerList{Items: test.rcs})}, nil + case m.isFor("PATCH", "/nodes/node"): + data, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + defer req.Body.Close() + oldJSON, err := runtime.Encode(codec, node) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + appliedPatch, err := strategicpatch.StrategicMergePatch(oldJSON, data, &corev1.Node{}) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + if err := runtime.DecodeInto(codec, appliedPatch, newNode); err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + if !reflect.DeepEqual(test.expected.Spec, newNode.Spec) { + t.Fatalf("%s: expected:\n%v\nsaw:\n%v\n", test.description, test.expected.Spec, newNode.Spec) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, newNode)}, nil + case m.isFor("DELETE", "/namespaces/default/pods/bar"): + atomic.AddInt32(&deletions, 1) + return &http.Response{StatusCode: 204, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &test.pods[0])}, nil + case m.isFor("POST", "/namespaces/default/pods/bar/eviction"): + + atomic.AddInt32(&evictions, 1) + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &policyv1beta1.Eviction{})}, nil + default: + t.Fatalf("%s: unexpected request: %v %#v\n%#v", test.description, req.Method, req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, _, errBuf := genericclioptions.NewTestIOStreams() + cmd := NewCmdDrain(tf, ioStreams) + + sawFatal := false + fatalMsg := "" + func() { + defer func() { + // Recover from the panic below. + _ = recover() + // Restore cmdutil behavior + cmdutil.DefaultBehaviorOnFatal() + }() + cmdutil.BehaviorOnFatal(func(e string, code int) { sawFatal = true; fatalMsg = e; panic(e) }) + cmd.SetArgs(test.args) + cmd.Execute() + }() + if test.expectFatal { + if !sawFatal { + //t.Logf("outBuf = %s", outBuf.String()) + //t.Logf("errBuf = %s", errBuf.String()) + t.Fatalf("%s: unexpected non-error when using %s", test.description, currMethod) + } + } else { + if sawFatal { + t.Fatalf("%s: unexpected error when using %s: %s", test.description, currMethod, fatalMsg) + + } + } + + deleted := deletions > 0 + evicted := evictions > 0 + + if test.expectDelete { + // Test Delete + if !testEviction && !deleted { + t.Fatalf("%s: pod never deleted", test.description) + } + // Test Eviction + if testEviction { + if !evicted { + t.Fatalf("%s: pod never evicted", test.description) + } + if evictions > 1 { + t.Fatalf("%s: asked to evict same pod %d too many times", test.description, evictions-1) + } + } + } + if !test.expectDelete { + if deleted { + t.Fatalf("%s: unexpected delete when using %s", test.description, currMethod) + } + if deletions > 1 { + t.Fatalf("%s: asked to deleted same pod %d too many times", test.description, deletions-1) + } + } + if deleted && evicted { + t.Fatalf("%s: same pod deleted %d times and evicted %d times", test.description, deletions, evictions) + } + + if len(test.expectWarning) > 0 { + if len(errBuf.String()) == 0 { + t.Fatalf("%s: expected warning, but found no stderr output", test.description) + } + + // Mac and Bazel on Linux behave differently when returning newlines + if a, e := errBuf.String(), test.expectWarning; !strings.Contains(a, e) { + t.Fatalf("%s: actual warning message did not match expected warning message.\n Expecting:\n%v\n Got:\n%v", test.description, e, a) + } + } + }) + } + } +} + +func TestDeletePods(t *testing.T) { + ifHasBeenCalled := map[string]bool{} + tests := []struct { + description string + interval time.Duration + timeout time.Duration + expectPendingPods bool + expectError bool + expectedError *error + getPodFn func(namespace, name string) (*corev1.Pod, error) + }{ + { + description: "Wait for deleting to complete", + interval: 100 * time.Millisecond, + timeout: 10 * time.Second, + expectPendingPods: false, + expectError: false, + expectedError: nil, + getPodFn: func(namespace, name string) (*corev1.Pod, error) { + oldPodMap, _ := createPods(false) + newPodMap, _ := createPods(true) + if oldPod, found := oldPodMap[name]; found { + if _, ok := ifHasBeenCalled[name]; !ok { + ifHasBeenCalled[name] = true + return &oldPod, nil + } + if oldPod.ObjectMeta.Generation < 4 { + newPod := newPodMap[name] + return &newPod, nil + } + return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "pods"}, name) + + } + return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "pods"}, name) + }, + }, + { + description: "Deleting could timeout", + interval: 200 * time.Millisecond, + timeout: 3 * time.Second, + expectPendingPods: true, + expectError: true, + expectedError: &wait.ErrWaitTimeout, + getPodFn: func(namespace, name string) (*corev1.Pod, error) { + oldPodMap, _ := createPods(false) + if oldPod, found := oldPodMap[name]; found { + return &oldPod, nil + } + return nil, fmt.Errorf("%q: not found", name) + }, + }, + { + description: "Client error could be passed out", + interval: 200 * time.Millisecond, + timeout: 5 * time.Second, + expectPendingPods: true, + expectError: true, + expectedError: nil, + getPodFn: func(namespace, name string) (*corev1.Pod, error) { + return nil, errors.New("This is a random error for testing") + }, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + tf := cmdtesting.NewTestFactory() + defer tf.Cleanup() + + o := DrainCmdOptions{ + PrintFlags: genericclioptions.NewPrintFlags("drained").WithTypeSetter(scheme.Scheme), + } + o.Out = os.Stdout + + o.ToPrinter = func(operation string) (printers.ResourcePrinterFunc, error) { + return func(obj runtime.Object, out io.Writer) error { + return nil + }, nil + } + + _, pods := createPods(false) + pendingPods, err := o.waitForDelete(pods, test.interval, test.timeout, false, test.getPodFn) + + if test.expectError { + if err == nil { + t.Fatalf("%s: unexpected non-error", test.description) + } else if test.expectedError != nil { + if *test.expectedError != err { + t.Fatalf("%s: the error does not match expected error", test.description) + } + } + } + if !test.expectError && err != nil { + t.Fatalf("%s: unexpected error", test.description) + } + if test.expectPendingPods && len(pendingPods) == 0 { + t.Fatalf("%s: unexpected empty pods", test.description) + } + if !test.expectPendingPods && len(pendingPods) > 0 { + t.Fatalf("%s: unexpected pending pods", test.description) + } + }) + } +} + +func createPods(ifCreateNewPods bool) (map[string]corev1.Pod, []corev1.Pod) { + podMap := make(map[string]corev1.Pod) + podSlice := []corev1.Pod{} + for i := 0; i < 8; i++ { + var uid types.UID + if ifCreateNewPods { + uid = types.UID(i) + } else { + uid = types.UID(strconv.Itoa(i) + strconv.Itoa(i)) + } + pod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod" + strconv.Itoa(i), + Namespace: "default", + UID: uid, + Generation: int64(i), + }, + } + podMap[pod.Name] = pod + podSlice = append(podSlice, pod) + } + return podMap, podSlice +} + +type MyReq struct { + Request *http.Request +} + +func (m *MyReq) isFor(method string, path string) bool { + req := m.Request + + return method == req.Method && (req.URL.Path == path || + req.URL.Path == strings.Join([]string{"/api/v1", path}, "") || + req.URL.Path == strings.Join([]string{"/apis/apps/v1", path}, "") || + req.URL.Path == strings.Join([]string{"/apis/batch/v1", path}, "")) +} diff --git a/pkg/cmd/edit/edit.go b/pkg/cmd/edit/edit.go new file mode 100644 index 000000000..0afee3f02 --- /dev/null +++ b/pkg/cmd/edit/edit.go @@ -0,0 +1,105 @@ +/* +Copyright 2015 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 edit + +import ( + "fmt" + + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/cmd/util/editor" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + editLong = templates.LongDesc(i18n.T(` + Edit a resource from the default editor. + + The edit command allows you to directly edit any API resource you can retrieve via the + command line tools. It will open the editor defined by your KUBE_EDITOR, or EDITOR + environment variables, or fall back to 'vi' for Linux or 'notepad' for Windows. + You can edit multiple objects, although changes are applied one at a time. The command + accepts filenames as well as command line arguments, although the files you point to must + be previously saved versions of resources. + + Editing is done with the API version used to fetch the resource. + To edit using a specific API version, fully-qualify the resource, version, and group. + + The default format is YAML. To edit in JSON, specify "-o json". + + The flag --windows-line-endings can be used to force Windows line endings, + otherwise the default for your operating system will be used. + + In the event an error occurs while updating, a temporary file will be created on disk + that contains your unapplied changes. The most common error when updating a resource + is another editor changing the resource on the server. When this occurs, you will have + to apply your changes to the newer version of the resource, or update your temporary + saved copy to include the latest resource version.`)) + + editExample = templates.Examples(i18n.T(` + # Edit the service named 'docker-registry': + kubectl edit svc/docker-registry + + # Use an alternative editor + KUBE_EDITOR="nano" kubectl edit svc/docker-registry + + # Edit the job 'myjob' in JSON using the v1 API format: + kubectl edit job.v1.batch/myjob -o json + + # Edit the deployment 'mydeployment' in YAML and save the modified config in its annotation: + kubectl edit deployment/mydeployment -o yaml --save-config`)) +) + +// NewCmdEdit creates the `edit` command +func NewCmdEdit(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := editor.NewEditOptions(editor.NormalEditMode, ioStreams) + o.ValidateOptions = cmdutil.ValidateOptions{EnableValidation: true} + + cmd := &cobra.Command{ + Use: "edit (RESOURCE/NAME | -f FILENAME)", + DisableFlagsInUseLine: true, + Short: i18n.T("Edit a resource on the server"), + Long: editLong, + Example: fmt.Sprintf(editExample), + Run: func(cmd *cobra.Command, args []string) { + if err := o.Complete(f, args, cmd); err != nil { + cmdutil.CheckErr(err) + } + if err := o.Run(); err != nil { + cmdutil.CheckErr(err) + } + }, + } + + // bind flag structs + o.RecordFlags.AddFlags(cmd) + o.PrintFlags.AddFlags(cmd) + + usage := "to use to edit the resource" + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + cmdutil.AddValidateOptionFlags(cmd, &o.ValidateOptions) + cmd.Flags().BoolVarP(&o.OutputPatch, "output-patch", "", o.OutputPatch, "Output the patch if the resource is edited.") + cmd.Flags().BoolVar(&o.WindowsLineEndings, "windows-line-endings", o.WindowsLineEndings, + "Defaults to the line ending native to your platform.") + + cmdutil.AddApplyAnnotationVarFlags(cmd, &o.ApplyAnnotation) + cmdutil.AddIncludeUninitializedFlag(cmd) + return cmd +} diff --git a/pkg/cmd/edit/edit_test.go b/pkg/cmd/edit/edit_test.go new file mode 100644 index 000000000..611175009 --- /dev/null +++ b/pkg/cmd/edit/edit_test.go @@ -0,0 +1,292 @@ +/* +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 edit + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + yaml "gopkg.in/yaml.v2" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/diff" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/rest/fake" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubernetes/pkg/kubectl/cmd/apply" + "k8s.io/kubernetes/pkg/kubectl/cmd/create" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +type EditTestCase struct { + Description string `yaml:"description"` + // create or edit + Mode string `yaml:"mode"` + Args []string `yaml:"args"` + Filename string `yaml:"filename"` + Output string `yaml:"outputFormat"` + OutputPatch string `yaml:"outputPatch"` + SaveConfig string `yaml:"saveConfig"` + Namespace string `yaml:"namespace"` + ExpectedStdout []string `yaml:"expectedStdout"` + ExpectedStderr []string `yaml:"expectedStderr"` + ExpectedExitCode int `yaml:"expectedExitCode"` + + Steps []EditStep `yaml:"steps"` +} + +type EditStep struct { + // edit or request + StepType string `yaml:"type"` + + // only applies to request + RequestMethod string `yaml:"expectedMethod,omitempty"` + RequestPath string `yaml:"expectedPath,omitempty"` + RequestContentType string `yaml:"expectedContentType,omitempty"` + Input string `yaml:"expectedInput"` + + // only applies to request + ResponseStatusCode int `yaml:"resultingStatusCode,omitempty"` + + Output string `yaml:"resultingOutput"` +} + +func TestEdit(t *testing.T) { + var ( + name string + testcase EditTestCase + i int + err error + ) + + const updateEnvVar = "UPDATE_EDIT_FIXTURE_DATA" + updateInputFixtures := os.Getenv(updateEnvVar) == "true" + + reqResp := func(req *http.Request) (*http.Response, error) { + defer func() { i++ }() + if i > len(testcase.Steps)-1 { + t.Fatalf("%s, step %d: more requests than steps, got %s %s", name, i, req.Method, req.URL.Path) + } + step := testcase.Steps[i] + + body := []byte{} + if req.Body != nil { + body, err = ioutil.ReadAll(req.Body) + if err != nil { + t.Fatalf("%s, step %d: %v", name, i, err) + } + } + + inputFile := filepath.Join("testdata", "testcase-"+name, step.Input) + expectedInput, err := ioutil.ReadFile(inputFile) + if err != nil { + t.Fatalf("%s, step %d: %v", name, i, err) + } + + outputFile := filepath.Join("testdata", "testcase-"+name, step.Output) + resultingOutput, err := ioutil.ReadFile(outputFile) + if err != nil { + t.Fatalf("%s, step %d: %v", name, i, err) + } + + if req.Method == "POST" && req.URL.Path == "/callback" { + if step.StepType != "edit" { + t.Fatalf("%s, step %d: expected edit step, got %s %s", name, i, req.Method, req.URL.Path) + } + if !bytes.Equal(body, expectedInput) { + if updateInputFixtures { + // Convenience to allow recapturing the input and persisting it here + ioutil.WriteFile(inputFile, body, os.FileMode(0644)) + } else { + t.Errorf("%s, step %d: diff in edit content:\n%s", name, i, diff.StringDiff(string(body), string(expectedInput))) + t.Logf("If the change in input is expected, rerun tests with %s=true to update input fixtures", updateEnvVar) + } + } + return &http.Response{StatusCode: 200, Body: ioutil.NopCloser(bytes.NewReader(resultingOutput))}, nil + } + if step.StepType != "request" { + t.Fatalf("%s, step %d: expected request step, got %s %s", name, i, req.Method, req.URL.Path) + } + body = tryIndent(body) + expectedInput = tryIndent(expectedInput) + if req.Method != step.RequestMethod || req.URL.Path != step.RequestPath || req.Header.Get("Content-Type") != step.RequestContentType { + t.Fatalf( + "%s, step %d: expected \n%s %s (content-type=%s)\ngot\n%s %s (content-type=%s)", name, i, + step.RequestMethod, step.RequestPath, step.RequestContentType, + req.Method, req.URL.Path, req.Header.Get("Content-Type"), + ) + } + if !bytes.Equal(body, expectedInput) { + if updateInputFixtures { + // Convenience to allow recapturing the input and persisting it here + ioutil.WriteFile(inputFile, body, os.FileMode(0644)) + } else { + t.Errorf("%s, step %d: diff in edit content:\n%s", name, i, diff.StringDiff(string(body), string(expectedInput))) + t.Logf("If the change in input is expected, rerun tests with %s=true to update input fixtures", updateEnvVar) + } + } + return &http.Response{StatusCode: step.ResponseStatusCode, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader(resultingOutput))}, nil + + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + resp, _ := reqResp(req) + for k, vs := range resp.Header { + w.Header().Del(k) + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) + }) + + server := httptest.NewServer(handler) + defer server.Close() + + os.Setenv("KUBE_EDITOR", "testdata/test_editor.sh") + os.Setenv("KUBE_EDITOR_CALLBACK", server.URL+"/callback") + + testcases := sets.NewString() + filepath.Walk("testdata", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if path == "testdata" { + return nil + } + name := filepath.Base(path) + if info.IsDir() { + if strings.HasPrefix(name, "testcase-") { + testcases.Insert(strings.TrimPrefix(name, "testcase-")) + } + return filepath.SkipDir + } + return nil + }) + // sanity check that we found the right folder + if !testcases.Has("create-list") { + t.Fatalf("Error locating edit testcases") + } + + for _, testcaseName := range testcases.List() { + t.Run(testcaseName, func(t *testing.T) { + i = 0 + name = testcaseName + testcase = EditTestCase{} + testcaseDir := filepath.Join("testdata", "testcase-"+name) + testcaseData, err := ioutil.ReadFile(filepath.Join(testcaseDir, "test.yaml")) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := yaml.Unmarshal(testcaseData, &testcase); err != nil { + t.Fatalf("%s: %v", name, err) + } + + tf := cmdtesting.NewTestFactory() + defer tf.Cleanup() + + tf.UnstructuredClientForMappingFunc = func(gv schema.GroupVersion) (resource.RESTClient, error) { + versionedAPIPath := "" + if gv.Group == "" { + versionedAPIPath = "/api/" + gv.Version + } else { + versionedAPIPath = "/apis/" + gv.Group + "/" + gv.Version + } + return &fake.RESTClient{ + VersionedAPIPath: versionedAPIPath, + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(reqResp), + }, nil + } + tf.WithNamespace(testcase.Namespace) + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + ioStreams, _, buf, errBuf := genericclioptions.NewTestIOStreams() + + var cmd *cobra.Command + switch testcase.Mode { + case "edit": + cmd = NewCmdEdit(tf, ioStreams) + case "create": + cmd = create.NewCmdCreate(tf, ioStreams) + cmd.Flags().Set("edit", "true") + case "edit-last-applied": + cmd = apply.NewCmdApplyEditLastApplied(tf, ioStreams) + default: + t.Fatalf("%s: unexpected mode %s", name, testcase.Mode) + } + if len(testcase.Filename) > 0 { + cmd.Flags().Set("filename", filepath.Join(testcaseDir, testcase.Filename)) + } + if len(testcase.Output) > 0 { + cmd.Flags().Set("output", testcase.Output) + } + if len(testcase.OutputPatch) > 0 { + cmd.Flags().Set("output-patch", testcase.OutputPatch) + } + if len(testcase.SaveConfig) > 0 { + cmd.Flags().Set("save-config", testcase.SaveConfig) + } + + cmdutil.BehaviorOnFatal(func(str string, code int) { + errBuf.WriteString(str) + if testcase.ExpectedExitCode != code { + t.Errorf("%s: expected exit code %d, got %d: %s", name, testcase.ExpectedExitCode, code, str) + } + }) + + cmd.Run(cmd, testcase.Args) + + stdout := buf.String() + stderr := errBuf.String() + + for _, s := range testcase.ExpectedStdout { + if !strings.Contains(stdout, s) { + t.Errorf("%s: expected to see '%s' in stdout\n\nstdout:\n%s\n\nstderr:\n%s", name, s, stdout, stderr) + } + } + for _, s := range testcase.ExpectedStderr { + if !strings.Contains(stderr, s) { + t.Errorf("%s: expected to see '%s' in stderr\n\nstdout:\n%s\n\nstderr:\n%s", name, s, stdout, stderr) + } + } + if i < len(testcase.Steps) { + t.Errorf("%s: saw %d steps, testcase included %d additional steps that were not exercised", name, i, len(testcase.Steps)-i) + } + }) + } +} + +func tryIndent(data []byte) []byte { + indented := &bytes.Buffer{} + if err := json.Indent(indented, data, "", "\t"); err == nil { + return indented.Bytes() + } + return data +} diff --git a/pkg/cmd/edit/testdata/README b/pkg/cmd/edit/testdata/README new file mode 100644 index 000000000..eb72b9271 --- /dev/null +++ b/pkg/cmd/edit/testdata/README @@ -0,0 +1,22 @@ +This folder contains test cases for interactive edit, and helpers for recording new test cases + +To record a new test: + +1. Start a local cluster running unsecured on http://localhost:8080 (e.g. hack/local-up-cluster.sh) +2. Set up any pre-existing resources you want to be available on that server (namespaces, resources to edit, etc) +3. Run ./pkg/kubectl/cmd/testdata/edit/record_testcase.sh my-testcase +4. Run the desired `kubectl edit ...` command, and interact with the editor as desired until it completes. + * You can do things that cause errors to appear in the editor (change immutable fields, fail validation, etc) + * You can perform edit flows that invoke the editor multiple times + * You can make out-of-band changes to the server resources that cause conflict errors to be returned + * The API requests/responses and editor inputs/outputs are captured in your testcase folder +5. Type exit. +6. Inspect the captured requests/responses and inputs/outputs for sanity +7. Modify the generated test.yaml file: + * Set a description of what the test is doing + * Enter the args (if any) you invoked edit with + * Enter the filename (if any) you invoked edit with + * Enter the output format (if any) you invoked edit with + * Optionally specify substrings to look for in the stdout or stderr of the edit command +8. Add your new testcase name to the list of testcases in edit_test.go +9. Run `go test ./pkg/kubectl/cmd -run TestEdit -v` to run edit tests diff --git a/pkg/cmd/edit/testdata/record.go b/pkg/cmd/edit/testdata/record.go new file mode 100644 index 000000000..95f15c64a --- /dev/null +++ b/pkg/cmd/edit/testdata/record.go @@ -0,0 +1,169 @@ +/* +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 main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "strings" + + yaml "gopkg.in/yaml.v2" +) + +type EditTestCase struct { + Description string `yaml:"description"` + // create or edit + Mode string `yaml:"mode"` + Args []string `yaml:"args"` + Filename string `yaml:"filename"` + Output string `yaml:"outputFormat"` + Namespace string `yaml:"namespace"` + ExpectedStdout []string `yaml:"expectedStdout"` + ExpectedStderr []string `yaml:"expectedStderr"` + ExpectedExitCode int `yaml:"expectedExitCode"` + + Steps []EditStep `yaml:"steps"` +} + +type EditStep struct { + // edit or request + StepType string `yaml:"type"` + + // only applies to request + RequestMethod string `yaml:"expectedMethod,omitempty"` + RequestPath string `yaml:"expectedPath,omitempty"` + RequestContentType string `yaml:"expectedContentType,omitempty"` + Input string `yaml:"expectedInput"` + + // only applies to request + ResponseStatusCode int `yaml:"resultingStatusCode,omitempty"` + + Output string `yaml:"resultingOutput"` +} + +func main() { + tc := &EditTestCase{ + Description: "add a testcase description", + Mode: "edit", + Args: []string{"set", "args"}, + ExpectedStdout: []string{"expected stdout substring"}, + ExpectedStderr: []string{"expected stderr substring"}, + } + + var currentStep *EditStep + + fmt.Println(http.ListenAndServe(":8081", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + + // Record non-discovery things + record := false + switch segments := strings.Split(strings.Trim(req.URL.Path, "/"), "/"); segments[0] { + case "api": + // api, version + record = len(segments) > 2 + case "apis": + // apis, group, version + record = len(segments) > 3 + case "callback": + record = true + } + + body, err := ioutil.ReadAll(req.Body) + checkErr(err) + + switch m, p := req.Method, req.URL.Path; { + case m == "POST" && p == "/callback/in": + if currentStep != nil { + panic("cannot post input with step already in progress") + } + filename := fmt.Sprintf("%d.original", len(tc.Steps)) + checkErr(ioutil.WriteFile(filename, body, os.FileMode(0755))) + currentStep = &EditStep{StepType: "edit", Input: filename} + case m == "POST" && p == "/callback/out": + if currentStep == nil || currentStep.StepType != "edit" { + panic("cannot post output without posting input first") + } + filename := fmt.Sprintf("%d.edited", len(tc.Steps)) + checkErr(ioutil.WriteFile(filename, body, os.FileMode(0755))) + currentStep.Output = filename + tc.Steps = append(tc.Steps, *currentStep) + currentStep = nil + default: + if currentStep != nil { + panic("cannot make request with step already in progress") + } + + urlCopy := *req.URL + urlCopy.Host = "localhost:8080" + urlCopy.Scheme = "http" + proxiedReq, err := http.NewRequest(req.Method, urlCopy.String(), bytes.NewReader(body)) + checkErr(err) + proxiedReq.Header = req.Header + resp, err := http.DefaultClient.Do(proxiedReq) + checkErr(err) + defer resp.Body.Close() + + bodyOut, err := ioutil.ReadAll(resp.Body) + checkErr(err) + + for k, vs := range resp.Header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + w.Write(bodyOut) + + if record { + infile := fmt.Sprintf("%d.request", len(tc.Steps)) + outfile := fmt.Sprintf("%d.response", len(tc.Steps)) + checkErr(ioutil.WriteFile(infile, tryIndent(body), os.FileMode(0755))) + checkErr(ioutil.WriteFile(outfile, tryIndent(bodyOut), os.FileMode(0755))) + tc.Steps = append(tc.Steps, EditStep{ + StepType: "request", + Input: infile, + Output: outfile, + RequestContentType: req.Header.Get("Content-Type"), + RequestMethod: req.Method, + RequestPath: req.URL.Path, + ResponseStatusCode: resp.StatusCode, + }) + } + } + + tcData, err := yaml.Marshal(tc) + checkErr(err) + checkErr(ioutil.WriteFile("test.yaml", tcData, os.FileMode(0755))) + }))) +} + +func checkErr(err error) { + if err != nil { + panic(err) + } +} + +func tryIndent(data []byte) []byte { + indented := &bytes.Buffer{} + if err := json.Indent(indented, data, "", "\t"); err == nil { + return indented.Bytes() + } + return data +} diff --git a/pkg/cmd/edit/testdata/record_editor.sh b/pkg/cmd/edit/testdata/record_editor.sh new file mode 100755 index 000000000..a2ba2d6fb --- /dev/null +++ b/pkg/cmd/edit/testdata/record_editor.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# 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. + +set -o errexit +set -o nounset +set -o pipefail + +# send the original content to the server +curl -s -k -XPOST "http://localhost:8081/callback/in" --data-binary "@${1}" +# allow the user to edit the file +vi "${1}" +# send the resulting content to the server +curl -s -k -XPOST "http://localhost:8081/callback/out" --data-binary "@${1}" diff --git a/pkg/cmd/edit/testdata/record_testcase.sh b/pkg/cmd/edit/testdata/record_testcase.sh new file mode 100755 index 000000000..541d98175 --- /dev/null +++ b/pkg/cmd/edit/testdata/record_testcase.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +# 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. + +set -o errexit +set -o nounset +set -o pipefail + +if [[ -z "${1-}" ]]; then + echo "Usage: record_testcase.sh testcase-name" + exit 1 +fi + +# Clean up the test server +function cleanup { + if [[ -n "${pid-}" ]]; then + echo "Stopping recording server (${pid})" + # kill the process `go run` launched + pkill -P "${pid}" + # kill the `go run` process itself + kill -9 "${pid}" + fi +} + +testcase="${1}" + +test_root="$(dirname "${BASH_SOURCE[0]}")" +testcase_dir="${test_root}/testcase-${testcase}" +mkdir -p "${testcase_dir}" + +pushd "${testcase_dir}" + export EDITOR="../record_editor.sh" + go run "../record.go" & + pid=$! + trap cleanup EXIT + echo "Started recording server (${pid})" + + # Make a kubeconfig that makes kubectl talk to our test server + edit_kubeconfig="${TMP:-/tmp}/edit_test.kubeconfig" + echo "apiVersion: v1 +clusters: +- cluster: + server: http://localhost:8081 + name: test +contexts: +- context: + cluster: test + user: test + name: test +current-context: test +kind: Config +users: [] +" > "${edit_kubeconfig}" + export KUBECONFIG="${edit_kubeconfig}" + + echo "Starting subshell. Type exit when finished." + bash +popd diff --git a/pkg/cmd/edit/testdata/test_editor.sh b/pkg/cmd/edit/testdata/test_editor.sh new file mode 100755 index 000000000..387e255d0 --- /dev/null +++ b/pkg/cmd/edit/testdata/test_editor.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# 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. + +set -o errexit +set -o nounset +set -o pipefail + +# Send the file content to the server +if command -v curl &>/dev/null; then + curl -s -k -XPOST --data-binary "@${1}" -o "${1}.result" "${KUBE_EDITOR_CALLBACK}" +elif command -v wget &>/dev/null; then + wget --post-file="${1}" -O "${1}.result" "${KUBE_EDITOR_CALLBACK}" +else + echo "curl and wget are unavailable" >&2 + exit 1 +fi + +# Use the response as the edited version +mv "${1}.result" "${1}" diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/0.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/0.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/0.response new file mode 100644 index 000000000..75d223aad --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/0.response @@ -0,0 +1,21 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/configmaps/cm1", + "uid": "cc08a131-3d6f-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3518", + "creationTimestamp": "2017-05-20T15:20:03Z", + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"baz\":\"qux\",\"foo\":\"changed-value\",\"new-data\":\"new-value\",\"new-data2\":\"new-value\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"name\":\"cm1\",\"namespace\":\"myproject\"}}\n" + } + }, + "data": { + "baz": "qux", + "foo": "changed-value", + "new-data": "new-value", + "new-data2": "new-value" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/1.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/1.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/1.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/1.response new file mode 100644 index 000000000..9703a466a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/1.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/services/svc1", + "uid": "d8b96f0b-3d6f-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3525", + "creationTimestamp": "2017-05-20T15:20:24Z", + "labels": { + "app": "svc1", + "new-label": "foo" + }, + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"svc1\",\"new-label\":\"foo\"},\"name\":\"svc1\",\"namespace\":\"myproject\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":81}],\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 81 + } + ], + "clusterIP": "172.30.32.183", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/2.edited b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/2.edited new file mode 100644 index 000000000..1769ec8fa --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/2.edited @@ -0,0 +1,38 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + new-data: new-value + new-data2: new-value + new-data3: newivalue + kind: ConfigMap + metadata: + annotations: {} + name: cm1 + namespace: myproject +- kind: Service + metadata: + annotations: {} + labels: + app: svc1 + new-label: foo + new-label2: foo2 + name: svc1 + namespace: myproject + spec: + ports: + - name: "80" + port: 82 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/2.original b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/2.original new file mode 100644 index 000000000..82770327c --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/2.original @@ -0,0 +1,36 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + new-data: new-value + new-data2: new-value + kind: ConfigMap + metadata: + annotations: {} + name: cm1 + namespace: myproject +- kind: Service + metadata: + annotations: {} + labels: + app: svc1 + new-label: foo + name: svc1 + namespace: myproject + spec: + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/3.edited b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/3.edited new file mode 100644 index 000000000..e5a7d241e --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/3.edited @@ -0,0 +1,41 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +# The edited file had a syntax error: error converting YAML to JSON: yaml: line 12: could not find expected ':' +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + new-data: new-value + new-data2: new-value + new-data3: newivalue + kind: ConfigMap + metadata: + annotations: {} + name: cm1 + namespace: myproject +- kind: Service + apiVersion: v1 + metadata: + annotations: {} + labels: + app: svc1 + new-label: foo + new-label2: foo2 + name: svc1 + namespace: myproject + spec: + ports: + - name: "80" + port: 82 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/3.original b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/3.original new file mode 100644 index 000000000..ede23048b --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/3.original @@ -0,0 +1,40 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +# The edited file had a syntax error: unable to get type info from the object "*unstructured.Unstructured": Object 'apiVersion' is missing in 'object has no apiVersion field' +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + new-data: new-value + new-data2: new-value + new-data3: newivalue + kind: ConfigMap + metadata: + annotations: {} + name: cm1 + namespace: myproject +- kind: Service + metadata: + annotations: {} + labels: + app: svc1 + new-label: foo + new-label2: foo2 + name: svc1 + namespace: myproject + spec: + ports: + - name: "80" + port: 82 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/4.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/4.request new file mode 100644 index 000000000..162c97e69 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/4.request @@ -0,0 +1,7 @@ +{ + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"baz\":\"qux\",\"foo\":\"changed-value\",\"new-data\":\"new-value\",\"new-data2\":\"new-value\",\"new-data3\":\"newivalue\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"name\":\"cm1\",\"namespace\":\"myproject\"}}\n" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/4.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/4.response new file mode 100644 index 000000000..e2115785e --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/4.response @@ -0,0 +1,21 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/configmaps/cm1", + "uid": "cc08a131-3d6f-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3554", + "creationTimestamp": "2017-05-20T15:20:03Z", + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"baz\":\"qux\",\"foo\":\"changed-value\",\"new-data\":\"new-value\",\"new-data2\":\"new-value\",\"new-data3\":\"newivalue\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"name\":\"cm1\",\"namespace\":\"myproject\"}}\n" + } + }, + "data": { + "baz": "qux", + "foo": "changed-value", + "new-data": "new-value", + "new-data2": "new-value" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/5.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/5.request new file mode 100644 index 000000000..a4768bcf4 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/5.request @@ -0,0 +1,7 @@ +{ + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"svc1\",\"new-label\":\"foo\",\"new-label2\":\"foo2\"},\"name\":\"svc1\",\"namespace\":\"myproject\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":82,\"protocol\":\"TCP\",\"targetPort\":81}],\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/5.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/5.response new file mode 100644 index 000000000..c538b4333 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/5.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/services/svc1", + "uid": "d8b96f0b-3d6f-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3555", + "creationTimestamp": "2017-05-20T15:20:24Z", + "labels": { + "app": "svc1", + "new-label": "foo" + }, + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"svc1\",\"new-label\":\"foo\",\"new-label2\":\"foo2\"},\"name\":\"svc1\",\"namespace\":\"myproject\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":82,\"protocol\":\"TCP\",\"targetPort\":81}],\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 81 + } + ], + "clusterIP": "172.30.32.183", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/test.yaml b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/test.yaml new file mode 100644 index 000000000..e1e80d5d1 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list-fail/test.yaml @@ -0,0 +1,43 @@ +description: if the user omits an API version, edit will fail +mode: edit-last-applied +args: +- configmaps/cm1 +- service/svc1 +namespace: "myproject" +expectedStdout: +- configmap/cm1 edited +- service/svc1 edited +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/myproject/configmaps/cm1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/myproject/services/svc1 + expectedInput: 1.request + resultingStatusCode: 200 + resultingOutput: 1.response +- type: edit + expectedInput: 2.original + resultingOutput: 2.edited +- type: edit + expectedInput: 3.original + resultingOutput: 3.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/myproject/configmaps/cm1 + expectedContentType: application/merge-patch+json + expectedInput: 4.request + resultingStatusCode: 200 + resultingOutput: 4.response +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/myproject/services/svc1 + expectedContentType: application/merge-patch+json + expectedInput: 5.request + resultingStatusCode: 200 + resultingOutput: 5.response diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/0.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/0.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/0.response new file mode 100644 index 000000000..75d223aad --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/0.response @@ -0,0 +1,21 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/configmaps/cm1", + "uid": "cc08a131-3d6f-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3518", + "creationTimestamp": "2017-05-20T15:20:03Z", + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"baz\":\"qux\",\"foo\":\"changed-value\",\"new-data\":\"new-value\",\"new-data2\":\"new-value\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"name\":\"cm1\",\"namespace\":\"myproject\"}}\n" + } + }, + "data": { + "baz": "qux", + "foo": "changed-value", + "new-data": "new-value", + "new-data2": "new-value" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/1.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/1.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/1.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/1.response new file mode 100644 index 000000000..9703a466a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/1.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/services/svc1", + "uid": "d8b96f0b-3d6f-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3525", + "creationTimestamp": "2017-05-20T15:20:24Z", + "labels": { + "app": "svc1", + "new-label": "foo" + }, + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"svc1\",\"new-label\":\"foo\"},\"name\":\"svc1\",\"namespace\":\"myproject\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":81}],\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 81 + } + ], + "clusterIP": "172.30.32.183", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/2.edited b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/2.edited new file mode 100644 index 000000000..490d5fbb0 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/2.edited @@ -0,0 +1,39 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + new-data: new-value + new-data2: new-value + new-data3: newivalue + kind: ConfigMap + metadata: + annotations: {} + name: cm1 + namespace: myproject +- kind: Service + apiVersion: v1 + metadata: + annotations: {} + labels: + app: svc1 + new-label: foo + new-label2: foo2 + name: svc1 + namespace: myproject + spec: + ports: + - name: "80" + port: 82 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/2.original b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/2.original new file mode 100644 index 000000000..82770327c --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/2.original @@ -0,0 +1,36 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + new-data: new-value + new-data2: new-value + kind: ConfigMap + metadata: + annotations: {} + name: cm1 + namespace: myproject +- kind: Service + metadata: + annotations: {} + labels: + app: svc1 + new-label: foo + name: svc1 + namespace: myproject + spec: + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/3.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/3.request new file mode 100644 index 000000000..162c97e69 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/3.request @@ -0,0 +1,7 @@ +{ + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"baz\":\"qux\",\"foo\":\"changed-value\",\"new-data\":\"new-value\",\"new-data2\":\"new-value\",\"new-data3\":\"newivalue\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"name\":\"cm1\",\"namespace\":\"myproject\"}}\n" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/3.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/3.response new file mode 100644 index 000000000..e2115785e --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/3.response @@ -0,0 +1,21 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/configmaps/cm1", + "uid": "cc08a131-3d6f-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3554", + "creationTimestamp": "2017-05-20T15:20:03Z", + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"baz\":\"qux\",\"foo\":\"changed-value\",\"new-data\":\"new-value\",\"new-data2\":\"new-value\",\"new-data3\":\"newivalue\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"name\":\"cm1\",\"namespace\":\"myproject\"}}\n" + } + }, + "data": { + "baz": "qux", + "foo": "changed-value", + "new-data": "new-value", + "new-data2": "new-value" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/4.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/4.request new file mode 100644 index 000000000..5c8be23d1 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/4.request @@ -0,0 +1,7 @@ +{ + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"svc1\",\"new-label\":\"foo\",\"new-label2\":\"foo2\"},\"name\":\"svc1\",\"namespace\":\"myproject\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":82,\"protocol\":\"TCP\",\"targetPort\":81}],\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/4.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/4.response new file mode 100644 index 000000000..dcf030403 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/4.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/services/svc1", + "uid": "d8b96f0b-3d6f-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3555", + "creationTimestamp": "2017-05-20T15:20:24Z", + "labels": { + "app": "svc1", + "new-label": "foo" + }, + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"svc1\",\"new-label\":\"foo\",\"new-label2\":\"foo2\"},\"name\":\"svc1\",\"namespace\":\"myproject\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":82,\"protocol\":\"TCP\",\"targetPort\":81}],\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 81 + } + ], + "clusterIP": "172.30.32.183", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/test.yaml b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/test.yaml new file mode 100644 index 000000000..9236dc984 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-list/test.yaml @@ -0,0 +1,40 @@ +description: add a testcase description +mode: edit-last-applied +args: +- configmaps/cm1 +- service/svc1 +namespace: "myproject" +expectedStdout: +- configmap/cm1 edited +- service/svc1 edited +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/myproject/configmaps/cm1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/myproject/services/svc1 + expectedInput: 1.request + resultingStatusCode: 200 + resultingOutput: 1.response +- type: edit + expectedInput: 2.original + resultingOutput: 2.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/myproject/configmaps/cm1 + expectedContentType: application/merge-patch+json + expectedInput: 3.request + resultingStatusCode: 200 + resultingOutput: 3.response +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/myproject/services/svc1 + expectedContentType: application/merge-patch+json + expectedInput: 4.request + resultingStatusCode: 200 + resultingOutput: 4.response diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/0.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/0.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/0.response new file mode 100644 index 000000000..23ee361bc --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/0.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/services/svc1", + "uid": "1e16d988-3d72-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3731", + "creationTimestamp": "2017-05-20T15:36:39Z", + "labels": { + "app": "svc1", + "new-label": "foo" + }, + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"svc1\",\"new-label\":\"foo\"},\"name\":\"svc1\",\"namespace\":\"myproject\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":81}],\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 81 + } + ], + "clusterIP": "172.30.105.209", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/1.edited b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/1.edited new file mode 100644 index 000000000..4f5e571f4 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/1.edited @@ -0,0 +1,21 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +kind: Service +metadata: + annotations: {} + labels: + app: svc1 + new-label: foo + name: svc1 + namespace: myproject +spec + ports: + name: "80" + port: 81 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: { diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/1.original b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/1.original new file mode 100644 index 000000000..8a290b4b1 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/1.original @@ -0,0 +1,21 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +kind: Service +metadata: + annotations: {} + labels: + app: svc1 + new-label: foo + name: svc1 + namespace: myproject +spec: + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/2.edited b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/2.edited new file mode 100644 index 000000000..f1d7af759 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/2.edited @@ -0,0 +1,24 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +# The edited file had a syntax error: error converting YAML to JSON: yaml: line 13: could not find expected ':' +# +kind: Service +metadata: + annotations: {} + labels: + app: svc1 + new-label: foo + new-label1: foo1 + name: svc1 + namespace: myproject +spec: + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/2.original b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/2.original new file mode 100644 index 000000000..c098aac27 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/2.original @@ -0,0 +1,23 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +# The edited file had a syntax error: error parsing edited-file: error converting YAML to JSON: yaml: line 13: could not find expected ':' +# +kind: Service +metadata: + annotations: {} + labels: + app: svc1 + new-label: foo + name: svc1 + namespace: myproject +spec + ports: + name: "80" + port: 81 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: { diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/3.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/3.request new file mode 100644 index 000000000..d4f72c452 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/3.request @@ -0,0 +1,7 @@ +{ + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"svc1\",\"new-label\":\"foo\",\"new-label1\":\"foo1\"},\"name\":\"svc1\",\"namespace\":\"myproject\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":81}],\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/3.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/3.response new file mode 100644 index 000000000..ca945db3e --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/3.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/services/svc1", + "uid": "1e16d988-3d72-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3857", + "creationTimestamp": "2017-05-20T15:36:39Z", + "labels": { + "app": "svc1", + "new-label": "foo" + }, + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"app\":\"svc1\",\"new-label\":\"foo\",\"new-label1\":\"foo1\"},\"name\":\"svc1\",\"namespace\":\"myproject\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":81}],\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 81 + } + ], + "clusterIP": "172.30.105.209", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/test.yaml b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/test.yaml new file mode 100644 index 000000000..df1a5db83 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied-syntax-error/test.yaml @@ -0,0 +1,28 @@ +description: edit with a syntax error, then re-edit and save +mode: edit-last-applied +args: +- service/svc1 +namespace: myproject +expectedStdout: +- "service/svc1 edited" +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/myproject/services/svc1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: edit + expectedInput: 2.original + resultingOutput: 2.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/myproject/services/svc1 + expectedContentType: application/merge-patch+json + expectedInput: 3.request + resultingStatusCode: 200 + resultingOutput: 3.response diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/0.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/0.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/0.response new file mode 100644 index 000000000..7bb8832af --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/0.response @@ -0,0 +1,38 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/services/svc1", + "uid": "bc66b442-3d6a-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3036", + "creationTimestamp": "2017-05-20T14:43:49Z", + "labels": { + "app": "svc1", + "new-label": "new-value" + }, + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"creationTimestamp\":\"2017-02-01T21:14:09Z\",\"labels\":{\"app\":\"svc1\",\"new-label\":\"new-value\"},\"name\":\"svc1\",\"namespace\":\"myproject\",\"resourceVersion\":\"20820\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":80}],\"selector\":{\"app\":\"svc1\"},\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "clusterIP": "172.30.136.24", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/1.edited b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/1.edited new file mode 100644 index 000000000..a37d3d406 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/1.edited @@ -0,0 +1,26 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +apiVersion: v1 +kind: Service +metadata: + annotations: {} + creationTimestamp: "2017-02-01T21:14:09Z" + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: myproject + resourceVersion: "20820" +spec: + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 92 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/1.original b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/1.original new file mode 100644 index 000000000..60b0d14ad --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/1.original @@ -0,0 +1,26 @@ +# Please edit the 'last-applied-configuration' annotations below. +# Lines beginning with a '#' will be ignored, and an empty file will abort the edit. +# +apiVersion: v1 +kind: Service +metadata: + annotations: {} + creationTimestamp: "2017-02-01T21:14:09Z" + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: myproject + resourceVersion: "20820" +spec: + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/2.request b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/2.request new file mode 100644 index 000000000..e00d0d0a9 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/2.request @@ -0,0 +1,7 @@ +{ + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"creationTimestamp\":\"2017-02-01T21:14:09Z\",\"labels\":{\"app\":\"svc1\",\"new-label\":\"new-value\"},\"name\":\"svc1\",\"namespace\":\"myproject\",\"resourceVersion\":\"20820\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":92}],\"selector\":{\"app\":\"svc1\"},\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/2.response b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/2.response new file mode 100644 index 000000000..d5b2b9b89 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/2.response @@ -0,0 +1,38 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "myproject", + "selfLink": "/api/v1/namespaces/myproject/services/svc1", + "uid": "bc66b442-3d6a-11e7-8ef0-c85b76034b7b", + "resourceVersion": "3093", + "creationTimestamp": "2017-05-20T14:43:49Z", + "labels": { + "app": "svc1", + "new-label": "new-value" + }, + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"creationTimestamp\":\"2017-02-01T21:14:09Z\",\"labels\":{\"app\":\"svc1\",\"new-label\":\"new-value\"},\"name\":\"svc1\",\"namespace\":\"myproject\",\"resourceVersion\":\"20820\"},\"spec\":{\"ports\":[{\"name\":\"80\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":92}],\"selector\":{\"app\":\"svc1\"},\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "clusterIP": "172.30.136.24", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/test.yaml b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/test.yaml new file mode 100644 index 000000000..106bfb963 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-apply-edit-last-applied/test.yaml @@ -0,0 +1,27 @@ +description: add a testcase description +mode: edit-last-applied +args: +- service +- svc1 +outputFormat: yaml +namespace: myproject +expectedStdout: +- 'targetPort: 92' +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/myproject/services/svc1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/myproject/services/svc1 + expectedContentType: application/merge-patch+json + expectedInput: 2.request + resultingStatusCode: 200 + resultingOutput: 2.response diff --git a/pkg/cmd/edit/testdata/testcase-create-list-error/0.edited b/pkg/cmd/edit/testdata/testcase-create-list-error/0.edited new file mode 100644 index 000000000..4cd5bf49e --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list-error/0.edited @@ -0,0 +1,28 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-03T06:44:47Z" + labels: + app: svc1modified + name: svc1 + namespace: edit-test + resourceVersion: "2942" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 4149f70e-e9dc-11e6-8c3b-acbc32c1ca87 +spec: + clusterIP: 10.0.0.118 + ports: + - name: "81" + port: 82 + protocol: TCP + targetPort: 81 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-create-list-error/0.original b/pkg/cmd/edit/testdata/testcase-create-list-error/0.original new file mode 100644 index 000000000..15ac94834 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list-error/0.original @@ -0,0 +1,28 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-03T06:44:47Z" + labels: + app: svc1 + name: svc1 + namespace: edit-test + resourceVersion: "2942" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 4149f70e-e9dc-11e6-8c3b-acbc32c1ca87 +spec: + clusterIP: 10.0.0.118 + ports: + - name: "81" + port: 81 + protocol: TCP + targetPort: 81 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-create-list-error/1.request b/pkg/cmd/edit/testdata/testcase-create-list-error/1.request new file mode 100644 index 000000000..460ffec4b --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list-error/1.request @@ -0,0 +1,33 @@ +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "creationTimestamp": "2017-02-03T06:44:47Z", + "labels": { + "app": "svc1modified" + }, + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "4149f70e-e9dc-11e6-8c3b-acbc32c1ca87" + }, + "spec": { + "clusterIP": "10.0.0.118", + "ports": [ + { + "name": "81", + "port": 82, + "protocol": "TCP", + "targetPort": 81 + } + ], + "selector": { + "app": "svc1" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-create-list-error/1.response b/pkg/cmd/edit/testdata/testcase-create-list-error/1.response new file mode 100644 index 000000000..61a0dea64 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list-error/1.response @@ -0,0 +1,34 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "c07152b8-e9dc-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "3171", + "creationTimestamp": "2017-02-03T06:48:21Z", + "labels": { + "app": "svc1modified" + } + }, + "spec": { + "ports": [ + { + "name": "81", + "protocol": "TCP", + "port": 82, + "targetPort": 81 + } + ], + "selector": { + "app": "svc1" + }, + "clusterIP": "10.0.0.118", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-create-list-error/2.edited b/pkg/cmd/edit/testdata/testcase-create-list-error/2.edited new file mode 100644 index 000000000..e70025ab8 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list-error/2.edited @@ -0,0 +1,28 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-03T06:44:43Z" + labels: + app: svc2modified + name: svc2 + namespace: edit-test + resourceVersion: "2936" + selfLink: /api/v1/namespaces/edit-test/services/svc2 + uid: 3e9b10db-e9dc-11e6-8c3b-acbc32c1ca87 +spec: + clusterIP: 10.0.0.182.1 + ports: + - name: "80" + port: 80 + protocol: VHF + targetPort: 80 + selector: + app: svc2 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-create-list-error/2.original b/pkg/cmd/edit/testdata/testcase-create-list-error/2.original new file mode 100644 index 000000000..fc1a72bd1 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list-error/2.original @@ -0,0 +1,28 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-03T06:44:43Z" + labels: + app: svc2 + name: svc2 + namespace: edit-test + resourceVersion: "2936" + selfLink: /api/v1/namespaces/edit-test/services/svc2 + uid: 3e9b10db-e9dc-11e6-8c3b-acbc32c1ca87 +spec: + clusterIP: 10.0.0.182 + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc2 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-create-list-error/3.request b/pkg/cmd/edit/testdata/testcase-create-list-error/3.request new file mode 100644 index 000000000..d18b4cad7 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list-error/3.request @@ -0,0 +1,33 @@ +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "creationTimestamp": "2017-02-03T06:44:43Z", + "labels": { + "app": "svc2modified" + }, + "name": "svc2", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc2", + "uid": "3e9b10db-e9dc-11e6-8c3b-acbc32c1ca87" + }, + "spec": { + "clusterIP": "10.0.0.182.1", + "ports": [ + { + "name": "80", + "port": 80, + "protocol": "VHF", + "targetPort": 80 + } + ], + "selector": { + "app": "svc2" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-create-list-error/3.response b/pkg/cmd/edit/testdata/testcase-create-list-error/3.response new file mode 100644 index 000000000..d47aa684f --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list-error/3.response @@ -0,0 +1,25 @@ +{ + "kind": "Status", + "apiVersion": "v1", + "metadata": {}, + "status": "Failure", + "message": "Service \"svc2\" is invalid: [spec.ports[0].protocol: Unsupported value: \"VHF\": supported values: TCP, UDP, SCTP, spec.clusterIP: Invalid value: \"10.0.0.182.1\": must be empty, 'None', or a valid IP address]", + "reason": "Invalid", + "details": { + "name": "svc2", + "kind": "Service", + "causes": [ + { + "reason": "FieldValueNotSupported", + "message": "Unsupported value: \"VHF\": supported values: TCP, UDP, SCTP", + "field": "spec.ports[0].protocol" + }, + { + "reason": "FieldValueInvalid", + "message": "Invalid value: \"10.0.0.182.1\": must be empty, 'None', or a valid IP address", + "field": "spec.clusterIP" + } + ] + }, + "code": 422 +} diff --git a/pkg/cmd/edit/testdata/testcase-create-list-error/svc.yaml b/pkg/cmd/edit/testdata/testcase-create-list-error/svc.yaml new file mode 100644 index 000000000..5eac741a2 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list-error/svc.yaml @@ -0,0 +1,54 @@ +apiVersion: v1 +items: +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-03T06:44:47Z" + labels: + app: svc1 + name: svc1 + namespace: edit-test + resourceVersion: "2942" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 4149f70e-e9dc-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.118 + ports: + - name: "81" + port: 81 + protocol: TCP + targetPort: 81 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-03T06:44:43Z" + labels: + app: svc2 + name: svc2 + namespace: edit-test + resourceVersion: "2936" + selfLink: /api/v1/namespaces/edit-test/services/svc2 + uid: 3e9b10db-e9dc-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.182 + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc2 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} +resourceVersion: "" +selfLink: "" diff --git a/pkg/cmd/edit/testdata/testcase-create-list-error/test.yaml b/pkg/cmd/edit/testdata/testcase-create-list-error/test.yaml new file mode 100644 index 000000000..e0bfe91f6 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list-error/test.yaml @@ -0,0 +1,30 @@ +description: create list with errors +mode: create +filename: "svc.yaml" +namespace: "edit-test" +expectedStdout: +- "service/svc1 created" +expectedStderr: +- "\"svc2\" is invalid" +expectedExitCode: 1 +steps: +- type: edit + expectedInput: 0.original + resultingOutput: 0.edited +- type: request + expectedMethod: POST + expectedPath: /api/v1/namespaces/edit-test/services + expectedContentType: application/json + expectedInput: 1.request + resultingStatusCode: 201 + resultingOutput: 1.response +- type: edit + expectedInput: 2.original + resultingOutput: 2.edited +- type: request + expectedMethod: POST + expectedPath: /api/v1/namespaces/edit-test/services + expectedContentType: application/json + expectedInput: 3.request + resultingStatusCode: 422 + resultingOutput: 3.response diff --git a/pkg/cmd/edit/testdata/testcase-create-list/0.edited b/pkg/cmd/edit/testdata/testcase-create-list/0.edited new file mode 100644 index 000000000..5781d6c01 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list/0.edited @@ -0,0 +1,22 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: edit-test +spec: + ports: + - name: "81" + port: 82 + protocol: TCP + targetPort: 81 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP diff --git a/pkg/cmd/edit/testdata/testcase-create-list/0.original b/pkg/cmd/edit/testdata/testcase-create-list/0.original new file mode 100644 index 000000000..d1db48e5a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list/0.original @@ -0,0 +1,21 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + labels: + app: svc1 + name: svc1 + namespace: edit-test +spec: + ports: + - name: "81" + port: 81 + protocol: TCP + targetPort: 81 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP diff --git a/pkg/cmd/edit/testdata/testcase-create-list/1.request b/pkg/cmd/edit/testdata/testcase-create-list/1.request new file mode 100644 index 000000000..39e94fef4 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list/1.request @@ -0,0 +1,27 @@ +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "svc1", + "new-label": "new-value" + }, + "name": "svc1", + "namespace": "edit-test" + }, + "spec": { + "ports": [ + { + "name": "81", + "port": 82, + "protocol": "TCP", + "targetPort": 81 + } + ], + "selector": { + "app": "svc1" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-create-list/1.response b/pkg/cmd/edit/testdata/testcase-create-list/1.response new file mode 100644 index 000000000..46af7748a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list/1.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "208b27ed-ea5b-11e6-9b42-acbc32c1ca87", + "resourceVersion": "1437", + "creationTimestamp": "2017-02-03T21:52:59Z", + "labels": { + "app": "svc1", + "new-label": "new-value" + } + }, + "spec": { + "ports": [ + { + "name": "81", + "protocol": "TCP", + "port": 82, + "targetPort": 81 + } + ], + "selector": { + "app": "svc1" + }, + "clusterIP": "10.0.0.15", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-create-list/2.edited b/pkg/cmd/edit/testdata/testcase-create-list/2.edited new file mode 100644 index 000000000..a93464e04 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list/2.edited @@ -0,0 +1,22 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + labels: + app: svc2 + name: svc2 + namespace: edit-test +spec: + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 81 + selector: + app: svc2 + new-label: new-value + sessionAffinity: None + type: ClusterIP diff --git a/pkg/cmd/edit/testdata/testcase-create-list/2.original b/pkg/cmd/edit/testdata/testcase-create-list/2.original new file mode 100644 index 000000000..e47dc6ab0 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list/2.original @@ -0,0 +1,21 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + labels: + app: svc2 + name: svc2 + namespace: edit-test +spec: + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc2 + sessionAffinity: None + type: ClusterIP diff --git a/pkg/cmd/edit/testdata/testcase-create-list/3.request b/pkg/cmd/edit/testdata/testcase-create-list/3.request new file mode 100644 index 000000000..91fd5ae24 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list/3.request @@ -0,0 +1,27 @@ +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "svc2" + }, + "name": "svc2", + "namespace": "edit-test" + }, + "spec": { + "ports": [ + { + "name": "80", + "port": 80, + "protocol": "TCP", + "targetPort": 81 + } + ], + "selector": { + "app": "svc2", + "new-label": "new-value" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-create-list/3.response b/pkg/cmd/edit/testdata/testcase-create-list/3.response new file mode 100644 index 000000000..7cba8df77 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list/3.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc2", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc2", + "uid": "31a1b8ae-ea5b-11e6-9b42-acbc32c1ca87", + "resourceVersion": "1470", + "creationTimestamp": "2017-02-03T21:53:27Z", + "labels": { + "app": "svc2" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 80, + "targetPort": 81 + } + ], + "selector": { + "app": "svc2", + "new-label": "new-value" + }, + "clusterIP": "10.0.0.55", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-create-list/svc.yaml b/pkg/cmd/edit/testdata/testcase-create-list/svc.yaml new file mode 100644 index 000000000..14ce81c8f --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list/svc.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +items: +- apiVersion: v1 + kind: Service + metadata: + labels: + app: svc1 + name: svc1 + spec: + ports: + - name: "81" + port: 81 + protocol: TCP + targetPort: 81 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +- apiVersion: v1 + kind: Service + metadata: + labels: + app: svc2 + name: svc2 + namespace: edit-test + spec: + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc2 + sessionAffinity: None + type: ClusterIP +kind: List +metadata: {} +resourceVersion: "" +selfLink: "" diff --git a/pkg/cmd/edit/testdata/testcase-create-list/test.yaml b/pkg/cmd/edit/testdata/testcase-create-list/test.yaml new file mode 100644 index 000000000..a3198e791 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-create-list/test.yaml @@ -0,0 +1,29 @@ +description: edit while creating from a list +mode: create +filename: "svc.yaml" +namespace: "edit-test" +expectedStdout: +- service/svc1 created +- service/svc2 created +expectedExitCode: 0 +steps: +- type: edit + expectedInput: 0.original + resultingOutput: 0.edited +- type: request + expectedMethod: POST + expectedPath: /api/v1/namespaces/edit-test/services + expectedContentType: application/json + expectedInput: 1.request + resultingStatusCode: 201 + resultingOutput: 1.response +- type: edit + expectedInput: 2.original + resultingOutput: 2.edited +- type: request + expectedMethod: POST + expectedPath: /api/v1/namespaces/edit-test/services + expectedContentType: application/json + expectedInput: 3.request + resultingStatusCode: 201 + resultingOutput: 3.response diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/0.request b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/0.response b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/0.response new file mode 100644 index 000000000..4fb08268e --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/0.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "5f7da8db-e8c3-11e6-b7e2-acbc32c1ca87", + "resourceVersion": "20820", + "creationTimestamp": "2017-02-01T21:14:09Z", + "labels": { + "app": "svc1", + "new-label": "new-value" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "clusterIP": "10.0.0.146", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/1.edited b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/1.edited new file mode 100644 index 000000000..80f7e593b --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/1.edited @@ -0,0 +1,29 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-01T21:14:09Z" + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: edit-test + resourceVersion: "20820" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 5f7da8db-e8c3-11e6-b7e2-acbc32c1ca87 +spec: + clusterIP: 10.0.0.146.1 + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/1.original b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/1.original new file mode 100644 index 000000000..18eae77d4 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/1.original @@ -0,0 +1,29 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-01T21:14:09Z" + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: edit-test + resourceVersion: "20820" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 5f7da8db-e8c3-11e6-b7e2-acbc32c1ca87 +spec: + clusterIP: 10.0.0.146 + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/2.request b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/2.request new file mode 100644 index 000000000..369735322 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/2.request @@ -0,0 +1,5 @@ +{ + "spec": { + "clusterIP": "10.0.0.146.1" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/2.response b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/2.response new file mode 100644 index 000000000..436b9d9ff --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/2.response @@ -0,0 +1,25 @@ +{ + "kind": "Status", + "apiVersion": "v1", + "metadata": {}, + "status": "Failure", + "message": "Service \"svc1\" is invalid: [spec.clusterIP: Invalid value: \"10.0.0.146.1\": field is immutable, spec.clusterIP: Invalid value: \"10.0.0.146.1\": must be empty, 'None', or a valid IP address]", + "reason": "Invalid", + "details": { + "name": "svc1", + "kind": "Service", + "causes": [ + { + "reason": "FieldValueInvalid", + "message": "Invalid value: \"10.0.0.146.1\": field is immutable", + "field": "spec.clusterIP" + }, + { + "reason": "FieldValueInvalid", + "message": "Invalid value: \"10.0.0.146.1\": must be empty, 'None', or a valid IP address", + "field": "spec.clusterIP" + } + ] + }, + "code": 422 +} diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/3.edited b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/3.edited new file mode 100644 index 000000000..f100978df --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/3.edited @@ -0,0 +1,33 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +# services "svc1" was not valid: +# * spec.clusterIP: Invalid value: "10.0.0.146.1": field is immutable +# * spec.clusterIP: Invalid value: "10.0.0.146.1": must be empty, 'None', or a valid IP address +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-01T21:14:09Z" + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: edit-test + resourceVersion: "20820" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 5f7da8db-e8c3-11e6-b7e2-acbc32c1ca87 +spec: + clusterIP: 10.0.0.146 + ports: + - name: "80" + port: 82 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/3.original b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/3.original new file mode 100644 index 000000000..4612722d7 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/3.original @@ -0,0 +1,33 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +# services "svc1" was not valid: +# * spec.clusterIP: Invalid value: "10.0.0.146.1": field is immutable +# * spec.clusterIP: Invalid value: "10.0.0.146.1": must be empty, 'None', or a valid IP address +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-01T21:14:09Z" + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: edit-test + resourceVersion: "20820" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 5f7da8db-e8c3-11e6-b7e2-acbc32c1ca87 +spec: + clusterIP: 10.0.0.146.1 + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/4.request b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/4.request new file mode 100644 index 000000000..c9c4f60ab --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/4.request @@ -0,0 +1,21 @@ +{ + "spec": { + "$setElementOrder/ports": [ + { + "port": 82 + } + ], + "ports": [ + { + "name": "80", + "port": 82, + "protocol": "TCP", + "targetPort": 80 + }, + { + "$patch": "delete", + "port": 81 + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/4.response b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/4.response new file mode 100644 index 000000000..a306fb410 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/4.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "5f7da8db-e8c3-11e6-b7e2-acbc32c1ca87", + "resourceVersion": "21361", + "creationTimestamp": "2017-02-01T21:14:09Z", + "labels": { + "app": "svc1", + "new-label": "new-value" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 82, + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "clusterIP": "10.0.0.146", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-edit-error-reedit/test.yaml b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/test.yaml new file mode 100644 index 000000000..0cc3a3705 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-error-reedit/test.yaml @@ -0,0 +1,38 @@ +description: add a testcase description +mode: edit +args: +- service +- svc1 +namespace: edit-test +expectedStdout: +- service/svc1 edited +expectedStderr: +- "error: services \"svc1\" is invalid" +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 2.request + resultingStatusCode: 422 + resultingOutput: 2.response +- type: edit + expectedInput: 3.original + resultingOutput: 3.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 4.request + resultingStatusCode: 200 + resultingOutput: 4.response diff --git a/pkg/cmd/edit/testdata/testcase-edit-from-empty/0.request b/pkg/cmd/edit/testdata/testcase-edit-from-empty/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-edit-from-empty/0.response b/pkg/cmd/edit/testdata/testcase-edit-from-empty/0.response new file mode 100644 index 000000000..e2a960dbb --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-from-empty/0.response @@ -0,0 +1,9 @@ +{ + "kind": "ConfigMapList", + "apiVersion": "v1", + "metadata": { + "selfLink": "/api/v1/namespaces/edit-test/configmaps", + "resourceVersion": "252" + }, + "items": [] +} diff --git a/pkg/cmd/edit/testdata/testcase-edit-from-empty/test.yaml b/pkg/cmd/edit/testdata/testcase-edit-from-empty/test.yaml new file mode 100644 index 000000000..7b7ecf895 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-from-empty/test.yaml @@ -0,0 +1,15 @@ +description: add a testcase description +mode: edit +args: +- configmap +namespace: "edit-test" +expectedStderr: +- edit cancelled, no objects found +expectedExitCode: 1 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/configmaps + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response diff --git a/pkg/cmd/edit/testdata/testcase-edit-output-patch/0.request b/pkg/cmd/edit/testdata/testcase-edit-output-patch/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-edit-output-patch/0.response b/pkg/cmd/edit/testdata/testcase-edit-output-patch/0.response new file mode 100644 index 000000000..b4693de49 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-output-patch/0.response @@ -0,0 +1,37 @@ +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"kind\":\"Service\",\"apiVersion\":\"v1\",\"metadata\":{\"name\":\"svc1\",\"creationTimestamp\":null,\"labels\":{\"app\":\"svc1\"}},\"spec\":{\"ports\":[{\"name\":\"80\",\"protocol\":\"TCP\",\"port\":80,\"targetPort\":80}],\"selector\":{\"app\":\"svc1\"},\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + }, + "creationTimestamp": "2017-02-27T19:40:53Z", + "labels": { + "app": "svc1" + }, + "name": "svc1", + "namespace": "edit-test", + "resourceVersion": "670", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "a6c11186-fd24-11e6-b53c-480fcf4a5275" + }, + "spec": { + "clusterIP": "10.0.0.204", + "ports": [ + { + "name": "80", + "port": 80, + "protocol": "TCP", + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-edit-output-patch/1.edited b/pkg/cmd/edit/testdata/testcase-edit-output-patch/1.edited new file mode 100644 index 000000000..51de60e11 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-output-patch/1.edited @@ -0,0 +1,32 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"kind":"Service","apiVersion":"v1","metadata":{"name":"svc1","creationTimestamp":null,"labels":{"app":"svc1"}},"spec":{"ports":[{"name":"80","protocol":"TCP","port":80,"targetPort":80}],"selector":{"app":"svc1"},"type":"ClusterIP"},"status":{"loadBalancer":{}}} + creationTimestamp: "2017-02-27T19:40:53Z" + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: edit-test + resourceVersion: "670" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: a6c11186-fd24-11e6-b53c-480fcf4a5275 +spec: + clusterIP: 10.0.0.204 + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-edit-output-patch/1.original b/pkg/cmd/edit/testdata/testcase-edit-output-patch/1.original new file mode 100644 index 000000000..38d71f247 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-output-patch/1.original @@ -0,0 +1,31 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"kind":"Service","apiVersion":"v1","metadata":{"name":"svc1","creationTimestamp":null,"labels":{"app":"svc1"}},"spec":{"ports":[{"name":"80","protocol":"TCP","port":80,"targetPort":80}],"selector":{"app":"svc1"},"type":"ClusterIP"},"status":{"loadBalancer":{}}} + creationTimestamp: "2017-02-27T19:40:53Z" + labels: + app: svc1 + name: svc1 + namespace: edit-test + resourceVersion: "670" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: a6c11186-fd24-11e6-b53c-480fcf4a5275 +spec: + clusterIP: 10.0.0.204 + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-edit-output-patch/2.request b/pkg/cmd/edit/testdata/testcase-edit-output-patch/2.request new file mode 100644 index 000000000..b26622634 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-output-patch/2.request @@ -0,0 +1,10 @@ +{ + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"creationTimestamp\":\"2017-02-27T19:40:53Z\",\"labels\":{\"app\":\"svc1\",\"new-label\":\"new-value\"},\"name\":\"svc1\",\"namespace\":\"edit-test\",\"resourceVersion\":\"670\",\"selfLink\":\"/api/v1/namespaces/edit-test/services/svc1\",\"uid\":\"a6c11186-fd24-11e6-b53c-480fcf4a5275\"},\"spec\":{\"clusterIP\":\"10.0.0.204\",\"ports\":[{\"name\":\"80\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":80}],\"selector\":{\"app\":\"svc1\"},\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + }, + "labels": { + "new-label": "new-value" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-edit-output-patch/2.response b/pkg/cmd/edit/testdata/testcase-edit-output-patch/2.response new file mode 100644 index 000000000..cf658cb8d --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-output-patch/2.response @@ -0,0 +1,38 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"creationTimestamp\":\"2017-02-27T19:40:53Z\",\"labels\":{\"app\":\"svc1\",\"new-label\":\"new-value\"},\"name\":\"svc1\",\"namespace\":\"edit-test\",\"resourceVersion\":\"670\",\"selfLink\":\"/api/v1/namespaces/edit-test/services/svc1\",\"uid\":\"a6c11186-fd24-11e6-b53c-480fcf4a5275\"},\"spec\":{\"clusterIP\":\"10.0.0.204\",\"ports\":[{\"name\":\"80\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":80}],\"selector\":{\"app\":\"svc1\"},\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + }, + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "a6c11186-fd24-11e6-b53c-480fcf4a5275", + "resourceVersion":"1045", + "creationTimestamp":"2017-02-27T19:40:53Z", + "labels": { + "app": "svc1", + "new-label": "new-value" + } + }, + "spec": { + "clusterIP": "10.0.0.204", + "ports": [ + { + "name": "80", + "port": 80, + "protocol": "TCP", + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-edit-output-patch/test.yaml b/pkg/cmd/edit/testdata/testcase-edit-output-patch/test.yaml new file mode 100644 index 000000000..1a6e13c50 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-edit-output-patch/test.yaml @@ -0,0 +1,32 @@ +# kubectl create namespace edit-test +# kubectl create service clusterip svc1 --tcp 80 --namespace=edit-test --save-config +# kubectl edit service svc1 --namespace=edit-test --save-config=true --output-patch=true +description: edit with flag --output-patch=true should output the patch +mode: edit +args: +- service +- svc1 +saveConfig: "true" +outputPatch: "true" +namespace: edit-test +expectedStdout: +- 'Patch: {"metadata":{"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"creationTimestamp\":\"2017-02-27T19:40:53Z\",\"labels\":{\"app\":\"svc1\",\"new-label\":\"new-value\"},\"name\":\"svc1\",\"namespace\":\"edit-test\",\"resourceVersion\":\"670\",\"selfLink\":\"/api/v1/namespaces/edit-test/services/svc1\",\"uid\":\"a6c11186-fd24-11e6-b53c-480fcf4a5275\"},\"spec\":{\"clusterIP\":\"10.0.0.204\",\"ports\":[{\"name\":\"80\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":80}],\"selector\":{\"app\":\"svc1\"},\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n"},"labels":{"new-label":"new-value"}}}' +- service/svc1 edited +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 2.request + resultingStatusCode: 200 + resultingOutput: 2.response diff --git a/pkg/cmd/edit/testdata/testcase-immutable-name/0.request b/pkg/cmd/edit/testdata/testcase-immutable-name/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-immutable-name/0.response b/pkg/cmd/edit/testdata/testcase-immutable-name/0.response new file mode 100644 index 000000000..f452881e0 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-immutable-name/0.response @@ -0,0 +1,24 @@ +{ + "kind": "ConfigMapList", + "apiVersion": "v1", + "metadata": { + "selfLink": "/api/v1/namespaces/edit-test/configmaps", + "resourceVersion": "2308" + }, + "items": [ + { + "metadata": { + "name": "cm1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/configmaps/cm1", + "uid": "b09bffab-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "2071", + "creationTimestamp": "2017-02-03T06:12:07Z" + }, + "data": { + "baz": "qux", + "foo": "changed-value2" + } + } + ] +} diff --git a/pkg/cmd/edit/testdata/testcase-immutable-name/1.edited b/pkg/cmd/edit/testdata/testcase-immutable-name/1.edited new file mode 100644 index 000000000..385c8b141 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-immutable-name/1.edited @@ -0,0 +1,16 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +data: + baz: qux + foo: changed-value2 +kind: ConfigMap +metadata: + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1-modified + namespace: edit-test + resourceVersion: "2071" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 diff --git a/pkg/cmd/edit/testdata/testcase-immutable-name/1.original b/pkg/cmd/edit/testdata/testcase-immutable-name/1.original new file mode 100644 index 000000000..c6a0dbaaf --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-immutable-name/1.original @@ -0,0 +1,16 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +data: + baz: qux + foo: changed-value2 +kind: ConfigMap +metadata: + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "2071" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 diff --git a/pkg/cmd/edit/testdata/testcase-immutable-name/test.yaml b/pkg/cmd/edit/testdata/testcase-immutable-name/test.yaml new file mode 100644 index 000000000..efcb39168 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-immutable-name/test.yaml @@ -0,0 +1,18 @@ +description: try to mutate a fixed field +mode: edit +args: +- configmap +namespace: "edit-test" +expectedStderr: +- At least one of apiVersion, kind and name was changed +expectedExitCode: 1 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/configmaps + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/0.request b/pkg/cmd/edit/testdata/testcase-list-errors/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/0.response b/pkg/cmd/edit/testdata/testcase-list-errors/0.response new file mode 100644 index 000000000..de6ce9b1d --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/0.response @@ -0,0 +1,24 @@ +{ + "kind": "ConfigMapList", + "apiVersion": "v1", + "metadata": { + "selfLink": "/api/v1/namespaces/edit-test/configmaps", + "resourceVersion": "1934" + }, + "items": [ + { + "metadata": { + "name": "cm1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/configmaps/cm1", + "uid": "b09bffab-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "1903", + "creationTimestamp": "2017-02-03T06:12:07Z" + }, + "data": { + "baz": "qux", + "foo": "changed-value" + } + } + ] +} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/1.request b/pkg/cmd/edit/testdata/testcase-list-errors/1.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/1.response b/pkg/cmd/edit/testdata/testcase-list-errors/1.response new file mode 100644 index 000000000..ef805f7dd --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/1.response @@ -0,0 +1,39 @@ +{ + "kind": "ServiceList", + "apiVersion": "v1", + "metadata": { + "selfLink": "/api/v1/namespaces/edit-test/services", + "resourceVersion": "1934" + }, + "items": [ + { + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "9bec82be-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "1904", + "creationTimestamp": "2017-02-03T06:11:32Z", + "labels": { + "app": "svc1" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 82, + "targetPort": 81 + } + ], + "clusterIP": "10.0.0.248", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } + } + ] +} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/10.request b/pkg/cmd/edit/testdata/testcase-list-errors/10.request new file mode 100644 index 000000000..5b1e95c83 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/10.request @@ -0,0 +1,5 @@ +{ + "data": { + "foo": "changed-value2" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/10.response b/pkg/cmd/edit/testdata/testcase-list-errors/10.response new file mode 100644 index 000000000..82f246ab7 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/10.response @@ -0,0 +1,16 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/configmaps/cm1", + "uid": "b09bffab-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "2071", + "creationTimestamp": "2017-02-03T06:12:07Z" + }, + "data": { + "baz": "qux", + "foo": "changed-value2" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/2.edited b/pkg/cmd/edit/testdata/testcase-list-errors/2.edited new file mode 100644 index 000000000..c0f056165 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/2.edited @@ -0,0 +1,42 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +items: +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-03T06:11:32Z" + labels: + app: svc1 + name: svc1 + namespace: edit-test + resourceVersion: "1904" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 9bec82be-e9d7-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.10 + ports: + - name: "80" + port: 82 + protocol: VHF + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +- apiVersion: v1 + data: + baz: qux + foo: changed-value2 + kind: ConfigMap + metadata: + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "1903" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/2.original b/pkg/cmd/edit/testdata/testcase-list-errors/2.original new file mode 100644 index 000000000..6a5dc923a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/2.original @@ -0,0 +1,42 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + kind: ConfigMap + metadata: + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "1903" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-03T06:11:32Z" + labels: + app: svc1 + name: svc1 + namespace: edit-test + resourceVersion: "1904" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 9bec82be-e9d7-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.248 + ports: + - name: "80" + port: 82 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/3.request b/pkg/cmd/edit/testdata/testcase-list-errors/3.request new file mode 100644 index 000000000..cbf369bb0 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/3.request @@ -0,0 +1,16 @@ +{ + "spec": { + "$setElementOrder/ports": [ + { + "port": 82 + } + ], + "clusterIP": "10.0.0.10", + "ports": [ + { + "port": 82, + "protocol": "VHF" + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/3.response b/pkg/cmd/edit/testdata/testcase-list-errors/3.response new file mode 100644 index 000000000..887b0b5fe --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/3.response @@ -0,0 +1,25 @@ +{ + "kind": "Status", + "apiVersion": "v1", + "metadata": {}, + "status": "Failure", + "message": "Service \"svc1\" is invalid: [spec.clusterIP: Invalid value: \"10.0.0.10\": field is immutable, spec.ports[0].protocol: Unsupported value: \"VHF\": supported values: TCP, UDP, SCTP]", + "reason": "Invalid", + "details": { + "name": "svc1", + "kind": "Service", + "causes": [ + { + "reason": "FieldValueInvalid", + "message": "Invalid value: \"10.0.0.10\": field is immutable", + "field": "spec.clusterIP" + }, + { + "reason": "FieldValueNotSupported", + "message": "Unsupported value: \"VHF\": supported values: TCP, UDP, SCTP", + "field": "spec.ports[0].protocol" + } + ] + }, + "code": 422 +} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/4.request b/pkg/cmd/edit/testdata/testcase-list-errors/4.request new file mode 100644 index 000000000..5b1e95c83 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/4.request @@ -0,0 +1,5 @@ +{ + "data": { + "foo": "changed-value2" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/4.response b/pkg/cmd/edit/testdata/testcase-list-errors/4.response new file mode 100644 index 000000000..1bf423cb5 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/4.response @@ -0,0 +1,16 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/configmaps/cm1", + "uid": "b09bffab-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "2017", + "creationTimestamp": "2017-02-03T06:12:07Z" + }, + "data": { + "baz": "qux", + "foo": "changed-value2" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/5.edited b/pkg/cmd/edit/testdata/testcase-list-errors/5.edited new file mode 100644 index 000000000..f4916d713 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/5.edited @@ -0,0 +1,47 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +# services "svc1" was not valid: +# * spec.clusterIP: Invalid value: "10.0.0.10": field is immutable +# * spec.ports[0].protocol: Unsupported value: "VHF": supported values: TCP, UDP, SCTP +# +apiVersion: v1 +items: +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-03T06:11:32Z" + labels: + app: svc1 + newvalue: modified + name: svc1 + namespace: edit-test + resourceVersion: "1904" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 9bec82be-e9d7-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.248 + ports: + - name: "80" + port: 83 + protocol: VHF + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +- apiVersion: v1 + data: + baz: qux + foo: changed-value2 + kind: ConfigMap + metadata: + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "1903" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/5.original b/pkg/cmd/edit/testdata/testcase-list-errors/5.original new file mode 100644 index 000000000..0eeeaff03 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/5.original @@ -0,0 +1,46 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +# services "svc1" was not valid: +# * spec.clusterIP: Invalid value: "10.0.0.10": field is immutable +# * spec.ports[0].protocol: Unsupported value: "VHF": supported values: TCP, UDP, SCTP +# +apiVersion: v1 +items: +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-03T06:11:32Z" + labels: + app: svc1 + name: svc1 + namespace: edit-test + resourceVersion: "1904" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 9bec82be-e9d7-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.10 + ports: + - name: "80" + port: 82 + protocol: VHF + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +- apiVersion: v1 + data: + baz: qux + foo: changed-value2 + kind: ConfigMap + metadata: + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "1903" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/6.request b/pkg/cmd/edit/testdata/testcase-list-errors/6.request new file mode 100644 index 000000000..90841f341 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/6.request @@ -0,0 +1,26 @@ +{ + "metadata": { + "labels": { + "newvalue": "modified" + } + }, + "spec": { + "$setElementOrder/ports": [ + { + "port": 83 + } + ], + "ports": [ + { + "name": "80", + "port": 83, + "protocol": "VHF", + "targetPort": 81 + }, + { + "$patch": "delete", + "port": 82 + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/6.response b/pkg/cmd/edit/testdata/testcase-list-errors/6.response new file mode 100644 index 000000000..eba919395 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/6.response @@ -0,0 +1,20 @@ +{ + "kind": "Status", + "apiVersion": "v1", + "metadata": {}, + "status": "Failure", + "message": "Service \"svc1\" is invalid: spec.ports[0].protocol: Unsupported value: \"VHF\": supported values: TCP, UDP, SCTP", + "reason": "Invalid", + "details": { + "name": "svc1", + "kind": "Service", + "causes": [ + { + "reason": "FieldValueNotSupported", + "message": "Unsupported value: \"VHF\": supported values: TCP, UDP, SCTP", + "field": "spec.ports[0].protocol" + } + ] + }, + "code": 422 +} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/7.request b/pkg/cmd/edit/testdata/testcase-list-errors/7.request new file mode 100644 index 000000000..5b1e95c83 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/7.request @@ -0,0 +1,5 @@ +{ + "data": { + "foo": "changed-value2" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/7.response b/pkg/cmd/edit/testdata/testcase-list-errors/7.response new file mode 100644 index 000000000..1bf423cb5 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/7.response @@ -0,0 +1,16 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/configmaps/cm1", + "uid": "b09bffab-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "2017", + "creationTimestamp": "2017-02-03T06:12:07Z" + }, + "data": { + "baz": "qux", + "foo": "changed-value2" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/8.edited b/pkg/cmd/edit/testdata/testcase-list-errors/8.edited new file mode 100644 index 000000000..a49105433 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/8.edited @@ -0,0 +1,46 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +# services "svc1" was not valid: +# * spec.ports[0].protocol: Unsupported value: "VHF": supported values: TCP, UDP, SCTP +# +apiVersion: v1 +items: +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-03T06:11:32Z" + labels: + app: svc1 + newvalue: modified + name: svc1 + namespace: edit-test + resourceVersion: "1904" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 9bec82be-e9d7-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.248 + ports: + - name: "80" + port: 83 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +- apiVersion: v1 + data: + baz: qux + foo: changed-value2 + kind: ConfigMap + metadata: + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "1903" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/8.original b/pkg/cmd/edit/testdata/testcase-list-errors/8.original new file mode 100644 index 000000000..f69f06c3a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/8.original @@ -0,0 +1,46 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +# services "svc1" was not valid: +# * spec.ports[0].protocol: Unsupported value: "VHF": supported values: TCP, UDP, SCTP +# +apiVersion: v1 +items: +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-03T06:11:32Z" + labels: + app: svc1 + newvalue: modified + name: svc1 + namespace: edit-test + resourceVersion: "1904" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 9bec82be-e9d7-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.248 + ports: + - name: "80" + port: 83 + protocol: VHF + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +- apiVersion: v1 + data: + baz: qux + foo: changed-value2 + kind: ConfigMap + metadata: + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "1903" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/9.request b/pkg/cmd/edit/testdata/testcase-list-errors/9.request new file mode 100644 index 000000000..794280402 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/9.request @@ -0,0 +1,26 @@ +{ + "metadata": { + "labels": { + "newvalue": "modified" + } + }, + "spec": { + "$setElementOrder/ports": [ + { + "port": 83 + } + ], + "ports": [ + { + "name": "80", + "port": 83, + "protocol": "TCP", + "targetPort": 81 + }, + { + "$patch": "delete", + "port": 82 + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/9.response b/pkg/cmd/edit/testdata/testcase-list-errors/9.response new file mode 100644 index 000000000..d88f99409 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/9.response @@ -0,0 +1,32 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "9bec82be-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "2070", + "creationTimestamp": "2017-02-03T06:11:32Z", + "labels": { + "app": "svc1", + "newvalue": "modified" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 83, + "targetPort": 81 + } + ], + "clusterIP": "10.0.0.248", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list-errors/test.yaml b/pkg/cmd/edit/testdata/testcase-list-errors/test.yaml new file mode 100644 index 000000000..a55cff618 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-errors/test.yaml @@ -0,0 +1,73 @@ +description: edit lists with errors and resubmit +mode: edit +args: +- configmaps,services +namespace: "edit-test" +expectedStdout: +- configmap/cm1 edited +- service/svc1 edited +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/configmaps + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/services + expectedInput: 1.request + resultingStatusCode: 200 + resultingOutput: 1.response +- type: edit + expectedInput: 2.original + resultingOutput: 2.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 3.request + resultingStatusCode: 422 + resultingOutput: 3.response +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/configmaps/cm1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 4.request + resultingStatusCode: 200 + resultingOutput: 4.response +- type: edit + expectedInput: 5.original + resultingOutput: 5.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 6.request + resultingStatusCode: 422 + resultingOutput: 6.response +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/configmaps/cm1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 7.request + resultingStatusCode: 200 + resultingOutput: 7.response +- type: edit + expectedInput: 8.original + resultingOutput: 8.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 9.request + resultingStatusCode: 200 + resultingOutput: 9.response +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/configmaps/cm1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 10.request + resultingStatusCode: 200 + resultingOutput: 10.response diff --git a/pkg/cmd/edit/testdata/testcase-list-record/0.request b/pkg/cmd/edit/testdata/testcase-list-record/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-list-record/0.response b/pkg/cmd/edit/testdata/testcase-list-record/0.response new file mode 100644 index 000000000..4a3492064 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-record/0.response @@ -0,0 +1,19 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/configmaps/cm1", + "uid": "b09bffab-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "1414", + "creationTimestamp": "2017-02-03T06:12:07Z", + "annotations":{"kubernetes.io/change-cause":"original creating command a"} + }, + "data": { + "baz": "qux", + "foo": "changed-value", + "new-data": "new-value", + "new-data2": "new-value" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list-record/1.request b/pkg/cmd/edit/testdata/testcase-list-record/1.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-list-record/1.response b/pkg/cmd/edit/testdata/testcase-list-record/1.response new file mode 100644 index 000000000..479dcaebb --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-record/1.response @@ -0,0 +1,33 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "9bec82be-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "1064", + "creationTimestamp": "2017-02-03T06:11:32Z", + "annotations":{"kubernetes.io/change-cause":"original creating command b"}, + "labels": { + "app": "svc1", + "new-label": "foo" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 81 + } + ], + "clusterIP": "10.0.0.248", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list-record/2.edited b/pkg/cmd/edit/testdata/testcase-list-record/2.edited new file mode 100644 index 000000000..5b93bde6a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-record/2.edited @@ -0,0 +1,51 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + new-data: new-value + new-data2: new-value + new-data3: newivalue + kind: ConfigMap + metadata: + annotations: + kubernetes.io/change-cause: original creating command a + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "1414" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 +- apiVersion: v1 + kind: Service + metadata: + annotations: + kubernetes.io/change-cause: original creating command b + creationTimestamp: "2017-02-03T06:11:32Z" + labels: + app: svc1 + new-label: foo + new-label2: foo2 + name: svc1 + namespace: edit-test + resourceVersion: "1064" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 9bec82be-e9d7-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.248 + ports: + - name: "80" + port: 82 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-list-record/2.original b/pkg/cmd/edit/testdata/testcase-list-record/2.original new file mode 100644 index 000000000..708d02d6f --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-record/2.original @@ -0,0 +1,49 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + new-data: new-value + new-data2: new-value + kind: ConfigMap + metadata: + annotations: + kubernetes.io/change-cause: original creating command a + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "1414" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 +- apiVersion: v1 + kind: Service + metadata: + annotations: + kubernetes.io/change-cause: original creating command b + creationTimestamp: "2017-02-03T06:11:32Z" + labels: + app: svc1 + new-label: foo + name: svc1 + namespace: edit-test + resourceVersion: "1064" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 9bec82be-e9d7-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.248 + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-list-record/3.request b/pkg/cmd/edit/testdata/testcase-list-record/3.request new file mode 100644 index 000000000..ba542f24b --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-record/3.request @@ -0,0 +1,5 @@ +{ + "data": { + "new-data3": "newivalue" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-list-record/3.response b/pkg/cmd/edit/testdata/testcase-list-record/3.response new file mode 100644 index 000000000..0292f244d --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-record/3.response @@ -0,0 +1,20 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/configmaps/cm1", + "uid": "b09bffab-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "1465", + "creationTimestamp": "2017-02-03T06:12:07Z", + "annotations":{"kubernetes.io/change-cause":"edit test cmd invocation"} + }, + "data": { + "baz": "qux", + "foo": "changed-value", + "new-data": "new-value", + "new-data2": "new-value", + "new-data3": "newivalue" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list-record/4.request b/pkg/cmd/edit/testdata/testcase-list-record/4.request new file mode 100644 index 000000000..572a71819 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-record/4.request @@ -0,0 +1,26 @@ +{ + "metadata": { + "labels": { + "new-label2": "foo2" + } + }, + "spec": { + "$setElementOrder/ports": [ + { + "port": 82 + } + ], + "ports": [ + { + "name": "80", + "port": 82, + "protocol": "TCP", + "targetPort": 81 + }, + { + "$patch": "delete", + "port": 81 + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-list-record/4.response b/pkg/cmd/edit/testdata/testcase-list-record/4.response new file mode 100644 index 000000000..64b4ac80a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-record/4.response @@ -0,0 +1,34 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "9bec82be-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "1466", + "creationTimestamp": "2017-02-03T06:11:32Z", + "annotations":{"kubernetes.io/change-cause":"edit test cmd invocation"}, + "labels": { + "app": "svc1", + "new-label": "foo", + "new-label2": "foo2" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 82, + "targetPort": 81 + } + ], + "clusterIP": "10.0.0.248", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list-record/test.yaml b/pkg/cmd/edit/testdata/testcase-list-record/test.yaml new file mode 100644 index 000000000..c4fd1ae17 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list-record/test.yaml @@ -0,0 +1,40 @@ +description: add a testcase description +mode: edit +args: +- configmaps/cm1 +- service/svc1 +namespace: "edit-test" +expectedStdout: +- configmap/cm1 edited +- service/svc1 edited +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/configmaps/cm1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedInput: 1.request + resultingStatusCode: 200 + resultingOutput: 1.response +- type: edit + expectedInput: 2.original + resultingOutput: 2.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/configmaps/cm1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 3.request + resultingStatusCode: 200 + resultingOutput: 3.response +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 4.request + resultingStatusCode: 200 + resultingOutput: 4.response diff --git a/pkg/cmd/edit/testdata/testcase-list/0.request b/pkg/cmd/edit/testdata/testcase-list/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-list/0.response b/pkg/cmd/edit/testdata/testcase-list/0.response new file mode 100644 index 000000000..98538a48e --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list/0.response @@ -0,0 +1,18 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/configmaps/cm1", + "uid": "b09bffab-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "1414", + "creationTimestamp": "2017-02-03T06:12:07Z" + }, + "data": { + "baz": "qux", + "foo": "changed-value", + "new-data": "new-value", + "new-data2": "new-value" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list/1.request b/pkg/cmd/edit/testdata/testcase-list/1.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-list/1.response b/pkg/cmd/edit/testdata/testcase-list/1.response new file mode 100644 index 000000000..4b734d584 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list/1.response @@ -0,0 +1,32 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "9bec82be-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "1064", + "creationTimestamp": "2017-02-03T06:11:32Z", + "labels": { + "app": "svc1", + "new-label": "foo" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 81 + } + ], + "clusterIP": "10.0.0.248", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list/2.edited b/pkg/cmd/edit/testdata/testcase-list/2.edited new file mode 100644 index 000000000..1c1674d04 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list/2.edited @@ -0,0 +1,47 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + new-data: new-value + new-data2: new-value + new-data3: newivalue + kind: ConfigMap + metadata: + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "1414" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-03T06:11:32Z" + labels: + app: svc1 + new-label: foo + new-label2: foo2 + name: svc1 + namespace: edit-test + resourceVersion: "1064" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 9bec82be-e9d7-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.248 + ports: + - name: "80" + port: 82 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-list/2.original b/pkg/cmd/edit/testdata/testcase-list/2.original new file mode 100644 index 000000000..6ae9a6f35 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list/2.original @@ -0,0 +1,45 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +items: +- apiVersion: v1 + data: + baz: qux + foo: changed-value + new-data: new-value + new-data2: new-value + kind: ConfigMap + metadata: + creationTimestamp: "2017-02-03T06:12:07Z" + name: cm1 + namespace: edit-test + resourceVersion: "1414" + selfLink: /api/v1/namespaces/edit-test/configmaps/cm1 + uid: b09bffab-e9d7-11e6-8c3b-acbc32c1ca87 +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-03T06:11:32Z" + labels: + app: svc1 + new-label: foo + name: svc1 + namespace: edit-test + resourceVersion: "1064" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 9bec82be-e9d7-11e6-8c3b-acbc32c1ca87 + spec: + clusterIP: 10.0.0.248 + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 81 + sessionAffinity: None + type: ClusterIP + status: + loadBalancer: {} +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-list/3.request b/pkg/cmd/edit/testdata/testcase-list/3.request new file mode 100644 index 000000000..ba542f24b --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list/3.request @@ -0,0 +1,5 @@ +{ + "data": { + "new-data3": "newivalue" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-list/3.response b/pkg/cmd/edit/testdata/testcase-list/3.response new file mode 100644 index 000000000..57aa154c9 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list/3.response @@ -0,0 +1,19 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "cm1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/configmaps/cm1", + "uid": "b09bffab-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "1465", + "creationTimestamp": "2017-02-03T06:12:07Z" + }, + "data": { + "baz": "qux", + "foo": "changed-value", + "new-data": "new-value", + "new-data2": "new-value", + "new-data3": "newivalue" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list/4.request b/pkg/cmd/edit/testdata/testcase-list/4.request new file mode 100644 index 000000000..572a71819 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list/4.request @@ -0,0 +1,26 @@ +{ + "metadata": { + "labels": { + "new-label2": "foo2" + } + }, + "spec": { + "$setElementOrder/ports": [ + { + "port": 82 + } + ], + "ports": [ + { + "name": "80", + "port": 82, + "protocol": "TCP", + "targetPort": 81 + }, + { + "$patch": "delete", + "port": 81 + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-list/4.response b/pkg/cmd/edit/testdata/testcase-list/4.response new file mode 100644 index 000000000..e242a83c6 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list/4.response @@ -0,0 +1,33 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "9bec82be-e9d7-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "1466", + "creationTimestamp": "2017-02-03T06:11:32Z", + "labels": { + "app": "svc1", + "new-label": "foo", + "new-label2": "foo2" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 82, + "targetPort": 81 + } + ], + "clusterIP": "10.0.0.248", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-list/test.yaml b/pkg/cmd/edit/testdata/testcase-list/test.yaml new file mode 100644 index 000000000..c4fd1ae17 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-list/test.yaml @@ -0,0 +1,40 @@ +description: add a testcase description +mode: edit +args: +- configmaps/cm1 +- service/svc1 +namespace: "edit-test" +expectedStdout: +- configmap/cm1 edited +- service/svc1 edited +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/configmaps/cm1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedInput: 1.request + resultingStatusCode: 200 + resultingOutput: 1.response +- type: edit + expectedInput: 2.original + resultingOutput: 2.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/configmaps/cm1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 3.request + resultingStatusCode: 200 + resultingOutput: 3.response +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 4.request + resultingStatusCode: 200 + resultingOutput: 4.response diff --git a/pkg/cmd/edit/testdata/testcase-missing-service/0.request b/pkg/cmd/edit/testdata/testcase-missing-service/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-missing-service/0.response b/pkg/cmd/edit/testdata/testcase-missing-service/0.response new file mode 100644 index 000000000..d55d10339 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-missing-service/0.response @@ -0,0 +1,13 @@ +{ + "kind": "Status", + "apiVersion": "v1", + "metadata": {}, + "status": "Failure", + "message": "services \"missing\" not found", + "reason": "NotFound", + "details": { + "name": "missing", + "kind": "services" + }, + "code": 404 +} diff --git a/pkg/cmd/edit/testdata/testcase-missing-service/test.yaml b/pkg/cmd/edit/testdata/testcase-missing-service/test.yaml new file mode 100644 index 000000000..87b05841a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-missing-service/test.yaml @@ -0,0 +1,15 @@ +description: add a testcase description +mode: edit +args: +- service/missing +namespace: "default" +expectedStderr: +- services "missing" not found +expectedExitCode: 1 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/default/services/missing + expectedInput: 0.request + resultingStatusCode: 404 + resultingOutput: 0.response diff --git a/pkg/cmd/edit/testdata/testcase-no-op/0.request b/pkg/cmd/edit/testdata/testcase-no-op/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-no-op/0.response b/pkg/cmd/edit/testdata/testcase-no-op/0.response new file mode 100644 index 000000000..0945091d1 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-no-op/0.response @@ -0,0 +1,12 @@ +{ + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": "mymap", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/configmaps/mymap", + "uid": "dbde42e9-e9d5-11e6-8c3b-acbc32c1ca87", + "resourceVersion": "149", + "creationTimestamp": "2017-02-03T05:59:00Z" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-no-op/1.edited b/pkg/cmd/edit/testdata/testcase-no-op/1.edited new file mode 100644 index 000000000..658dc20ed --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-no-op/1.edited @@ -0,0 +1,13 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: ConfigMap +metadata: + creationTimestamp: "2017-02-03T05:59:00Z" + name: mymap + namespace: default + resourceVersion: "149" + selfLink: /api/v1/namespaces/default/configmaps/mymap + uid: dbde42e9-e9d5-11e6-8c3b-acbc32c1ca87 diff --git a/pkg/cmd/edit/testdata/testcase-no-op/1.original b/pkg/cmd/edit/testdata/testcase-no-op/1.original new file mode 100644 index 000000000..658dc20ed --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-no-op/1.original @@ -0,0 +1,13 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: ConfigMap +metadata: + creationTimestamp: "2017-02-03T05:59:00Z" + name: mymap + namespace: default + resourceVersion: "149" + selfLink: /api/v1/namespaces/default/configmaps/mymap + uid: dbde42e9-e9d5-11e6-8c3b-acbc32c1ca87 diff --git a/pkg/cmd/edit/testdata/testcase-no-op/test.yaml b/pkg/cmd/edit/testdata/testcase-no-op/test.yaml new file mode 100644 index 000000000..6b7b108fd --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-no-op/test.yaml @@ -0,0 +1,18 @@ +description: no-op edit +mode: edit +args: +- configmap/mymap +namespace: "default" +expectedStderr: +- Edit cancelled, no changes made. +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/default/configmaps/mymap + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited diff --git a/pkg/cmd/edit/testdata/testcase-not-update-annotation/0.request b/pkg/cmd/edit/testdata/testcase-not-update-annotation/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-not-update-annotation/0.response b/pkg/cmd/edit/testdata/testcase-not-update-annotation/0.response new file mode 100644 index 000000000..b4693de49 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-not-update-annotation/0.response @@ -0,0 +1,37 @@ +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"kind\":\"Service\",\"apiVersion\":\"v1\",\"metadata\":{\"name\":\"svc1\",\"creationTimestamp\":null,\"labels\":{\"app\":\"svc1\"}},\"spec\":{\"ports\":[{\"name\":\"80\",\"protocol\":\"TCP\",\"port\":80,\"targetPort\":80}],\"selector\":{\"app\":\"svc1\"},\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + }, + "creationTimestamp": "2017-02-27T19:40:53Z", + "labels": { + "app": "svc1" + }, + "name": "svc1", + "namespace": "edit-test", + "resourceVersion": "670", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "a6c11186-fd24-11e6-b53c-480fcf4a5275" + }, + "spec": { + "clusterIP": "10.0.0.204", + "ports": [ + { + "name": "80", + "port": 80, + "protocol": "TCP", + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-not-update-annotation/1.edited b/pkg/cmd/edit/testdata/testcase-not-update-annotation/1.edited new file mode 100644 index 000000000..51de60e11 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-not-update-annotation/1.edited @@ -0,0 +1,32 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"kind":"Service","apiVersion":"v1","metadata":{"name":"svc1","creationTimestamp":null,"labels":{"app":"svc1"}},"spec":{"ports":[{"name":"80","protocol":"TCP","port":80,"targetPort":80}],"selector":{"app":"svc1"},"type":"ClusterIP"},"status":{"loadBalancer":{}}} + creationTimestamp: "2017-02-27T19:40:53Z" + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: edit-test + resourceVersion: "670" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: a6c11186-fd24-11e6-b53c-480fcf4a5275 +spec: + clusterIP: 10.0.0.204 + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-not-update-annotation/1.original b/pkg/cmd/edit/testdata/testcase-not-update-annotation/1.original new file mode 100644 index 000000000..38d71f247 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-not-update-annotation/1.original @@ -0,0 +1,31 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"kind":"Service","apiVersion":"v1","metadata":{"name":"svc1","creationTimestamp":null,"labels":{"app":"svc1"}},"spec":{"ports":[{"name":"80","protocol":"TCP","port":80,"targetPort":80}],"selector":{"app":"svc1"},"type":"ClusterIP"},"status":{"loadBalancer":{}}} + creationTimestamp: "2017-02-27T19:40:53Z" + labels: + app: svc1 + name: svc1 + namespace: edit-test + resourceVersion: "670" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: a6c11186-fd24-11e6-b53c-480fcf4a5275 +spec: + clusterIP: 10.0.0.204 + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-not-update-annotation/2.request b/pkg/cmd/edit/testdata/testcase-not-update-annotation/2.request new file mode 100644 index 000000000..bd858120d --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-not-update-annotation/2.request @@ -0,0 +1,7 @@ +{ + "metadata": { + "labels": { + "new-label": "new-value" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-not-update-annotation/2.response b/pkg/cmd/edit/testdata/testcase-not-update-annotation/2.response new file mode 100644 index 000000000..cacd4cc33 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-not-update-annotation/2.response @@ -0,0 +1,38 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"creationTimestamp\":\"2017-02-27T19:40:53Z\",\"labels\":{\"app\":\"svc1\"},\"name\":\"svc1\",\"namespace\":\"edit-test\",\"resourceVersion\":\"670\",\"selfLink\":\"/api/v1/namespaces/edit-test/services/svc1\",\"uid\":\"a6c11186-fd24-11e6-b53c-480fcf4a5275\"},\"spec\":{\"clusterIP\":\"10.0.0.204\",\"ports\":[{\"name\":\"80\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":80}],\"selector\":{\"app\":\"svc1\"},\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + }, + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "a6c11186-fd24-11e6-b53c-480fcf4a5275", + "resourceVersion":"1045", + "creationTimestamp":"2017-02-27T19:40:53Z", + "labels": { + "app": "svc1", + "new-label": "new-value" + } + }, + "spec": { + "clusterIP": "10.0.0.204", + "ports": [ + { + "name": "80", + "port": 80, + "protocol": "TCP", + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-not-update-annotation/test.yaml b/pkg/cmd/edit/testdata/testcase-not-update-annotation/test.yaml new file mode 100644 index 000000000..a78dc296a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-not-update-annotation/test.yaml @@ -0,0 +1,30 @@ +# kubectl create namespace edit-test +# kubectl create service clusterip svc1 --tcp 80 --namespace=edit-test --save-config +# kubectl edit service svc1 --namespace=edit-test --save-config=false +description: edit with flag --save-config=false should not update the annotation +mode: edit +args: +- service +- svc1 +saveConfig: "false" +namespace: edit-test +expectedStdout: +- service/svc1 edited +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 2.request + resultingStatusCode: 200 + resultingOutput: 2.response diff --git a/pkg/cmd/edit/testdata/testcase-repeat-error/0.request b/pkg/cmd/edit/testdata/testcase-repeat-error/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-repeat-error/0.response b/pkg/cmd/edit/testdata/testcase-repeat-error/0.response new file mode 100644 index 000000000..6a6a6ac70 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-repeat-error/0.response @@ -0,0 +1,32 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "kubernetes", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/kubernetes", + "uid": "6a8e8829-f15f-11e6-b041-acbc32c1ca87", + "resourceVersion": "8", + "creationTimestamp": "2017-02-12T20:11:19Z", + "labels": { + "component": "apiserver", + "provider": "kubernetes" + } + }, + "spec": { + "ports": [ + { + "name": "https", + "protocol": "TCP", + "port": 443, + "targetPort": 443 + } + ], + "clusterIP": "10.0.0.1", + "type": "ClusterIP", + "sessionAffinity": "ClientIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-repeat-error/1.edited b/pkg/cmd/edit/testdata/testcase-repeat-error/1.edited new file mode 100644 index 000000000..6bd4ae452 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-repeat-error/1.edited @@ -0,0 +1,27 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-12T20:11:19Z" + labels: + component: apiserver + provider: kubernetes + name: kubernetes + namespace: default + resourceVersion: "8" + selfLink: /api/v1/namespaces/default/services/kubernetes + uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87 +spec: + clusterIP: 10.0.0.1.1 + ports: + - name: https + port: 443 + protocol: TCP + targetPort: 443 + sessionAffinity: ClientIP + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-repeat-error/1.original b/pkg/cmd/edit/testdata/testcase-repeat-error/1.original new file mode 100644 index 000000000..ce6c5ce35 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-repeat-error/1.original @@ -0,0 +1,27 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-12T20:11:19Z" + labels: + component: apiserver + provider: kubernetes + name: kubernetes + namespace: default + resourceVersion: "8" + selfLink: /api/v1/namespaces/default/services/kubernetes + uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87 +spec: + clusterIP: 10.0.0.1 + ports: + - name: https + port: 443 + protocol: TCP + targetPort: 443 + sessionAffinity: ClientIP + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-repeat-error/2.request b/pkg/cmd/edit/testdata/testcase-repeat-error/2.request new file mode 100644 index 000000000..d828ee85f --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-repeat-error/2.request @@ -0,0 +1,5 @@ +{ + "spec": { + "clusterIP": "10.0.0.1.1" + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-repeat-error/2.response b/pkg/cmd/edit/testdata/testcase-repeat-error/2.response new file mode 100644 index 000000000..29cfaa0a4 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-repeat-error/2.response @@ -0,0 +1,25 @@ +{ + "kind": "Status", + "apiVersion": "v1", + "metadata": {}, + "status": "Failure", + "message": "Service \"kubernetes\" is invalid: [spec.clusterIP: Invalid value: \"10.0.0.1.1\": field is immutable, spec.clusterIP: Invalid value: \"10.0.0.1.1\": must be empty, 'None', or a valid IP address]", + "reason": "Invalid", + "details": { + "name": "kubernetes", + "kind": "Service", + "causes": [ + { + "reason": "FieldValueInvalid", + "message": "Invalid value: \"10.0.0.1.1\": field is immutable", + "field": "spec.clusterIP" + }, + { + "reason": "FieldValueInvalid", + "message": "Invalid value: \"10.0.0.1.1\": must be empty, 'None', or a valid IP address", + "field": "spec.clusterIP" + } + ] + }, + "code": 422 +} diff --git a/pkg/cmd/edit/testdata/testcase-repeat-error/3.edited b/pkg/cmd/edit/testdata/testcase-repeat-error/3.edited new file mode 100644 index 000000000..b83ab430a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-repeat-error/3.edited @@ -0,0 +1,31 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +# services "kubernetes" was not valid: +# * spec.clusterIP: Invalid value: "10.0.0.1.1": field is immutable +# * spec.clusterIP: Invalid value: "10.0.0.1.1": must be empty, 'None', or a valid IP address +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-12T20:11:19Z" + labels: + component: apiserver + provider: kubernetes + name: kubernetes + namespace: default + resourceVersion: "8" + selfLink: /api/v1/namespaces/default/services/kubernetes + uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87 +spec: + clusterIP: 10.0.0.1.1 + ports: + - name: https + port: 443 + protocol: TCP + targetPort: 443 + sessionAffinity: ClientIP + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-repeat-error/3.original b/pkg/cmd/edit/testdata/testcase-repeat-error/3.original new file mode 100644 index 000000000..b83ab430a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-repeat-error/3.original @@ -0,0 +1,31 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +# services "kubernetes" was not valid: +# * spec.clusterIP: Invalid value: "10.0.0.1.1": field is immutable +# * spec.clusterIP: Invalid value: "10.0.0.1.1": must be empty, 'None', or a valid IP address +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-12T20:11:19Z" + labels: + component: apiserver + provider: kubernetes + name: kubernetes + namespace: default + resourceVersion: "8" + selfLink: /api/v1/namespaces/default/services/kubernetes + uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87 +spec: + clusterIP: 10.0.0.1.1 + ports: + - name: https + port: 443 + protocol: TCP + targetPort: 443 + sessionAffinity: ClientIP + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-repeat-error/test.yaml b/pkg/cmd/edit/testdata/testcase-repeat-error/test.yaml new file mode 100644 index 000000000..a4ef37f05 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-repeat-error/test.yaml @@ -0,0 +1,30 @@ +description: add a testcase description +mode: edit +args: +- service/kubernetes +namespace: default +expectedStderr: +- "services \"kubernetes\" is invalid" +- A copy of your changes has been stored +- Edit cancelled, no valid changes were saved +expectedExitCode: 1 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/default/services/kubernetes + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/default/services/kubernetes + expectedContentType: application/strategic-merge-patch+json + expectedInput: 2.request + resultingStatusCode: 422 + resultingOutput: 2.response +- type: edit + expectedInput: 3.original + resultingOutput: 3.edited diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/0.request b/pkg/cmd/edit/testdata/testcase-schemaless-list/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/0.response b/pkg/cmd/edit/testdata/testcase-schemaless-list/0.response new file mode 100644 index 000000000..1bc014482 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/0.response @@ -0,0 +1,32 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "kubernetes", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/kubernetes", + "uid": "6a8e8829-f15f-11e6-b041-acbc32c1ca87", + "resourceVersion": "16953", + "creationTimestamp": "2017-02-12T20:11:19Z", + "labels": { + "component": "apiserver", + "provider": "kubernetes" + } + }, + "spec": { + "ports": [ + { + "name": "https", + "protocol": "TCP", + "port": 443, + "targetPort": 443 + } + ], + "clusterIP": "10.0.0.1", + "type": "ClusterIP", + "sessionAffinity": "ClientIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/1.request b/pkg/cmd/edit/testdata/testcase-schemaless-list/1.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/1.response b/pkg/cmd/edit/testdata/testcase-schemaless-list/1.response new file mode 100644 index 000000000..b2519124b --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/1.response @@ -0,0 +1,16 @@ +{ + "apiVersion": "company.com/v1", + "kind": "Bar", + "metadata": { + "name": "test", + "namespace": "default", + "selfLink": "/apis/company.com/v1/namespaces/default/bars/test", + "uid": "fd16c23d-f185-11e6-b041-acbc32c1ca87", + "resourceVersion": "16954", + "creationTimestamp": "2017-02-13T00:47:26Z" + }, + "some-field": "field1", + "third-field": { + "sub-field": "bar2" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/2.request b/pkg/cmd/edit/testdata/testcase-schemaless-list/2.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/2.response b/pkg/cmd/edit/testdata/testcase-schemaless-list/2.response new file mode 100644 index 000000000..c3a509b81 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/2.response @@ -0,0 +1,21 @@ +{ + "apiVersion": "company.com/v1", + "field1": "value1", + "field2": true, + "field3": [ + 1 + ], + "field4": { + "a": true, + "b": false + }, + "kind": "Bar", + "metadata": { + "name": "test2", + "namespace": "default", + "selfLink": "/apis/company.com/v1/namespaces/default/bars/test2", + "uid": "5ef5b446-f186-11e6-b041-acbc32c1ca87", + "resourceVersion": "16955", + "creationTimestamp": "2017-02-13T00:50:10Z" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/3.edited b/pkg/cmd/edit/testdata/testcase-schemaless-list/3.edited new file mode 100644 index 000000000..fa5a108e9 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/3.edited @@ -0,0 +1,62 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +items: +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-12T20:11:19Z" + labels: + component: apiserver + provider: kubernetes + new-label: new-value + name: kubernetes + namespace: default + resourceVersion: "16953" + selfLink: /api/v1/namespaces/default/services/kubernetes + uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87 + spec: + clusterIP: 10.0.0.1 + ports: + - name: https + port: 443 + protocol: TCP + targetPort: 443 + sessionAffinity: ClientIP + type: ClusterIP + status: + loadBalancer: {} +- apiVersion: company.com/v1 + kind: Bar + metadata: + creationTimestamp: "2017-02-13T00:47:26Z" + name: test + namespace: default + resourceVersion: "16954" + selfLink: /apis/company.com/v1/namespaces/default/bars/test + uid: fd16c23d-f185-11e6-b041-acbc32c1ca87 + some-field: field1 + other-field: other-value + third-field: + sub-field: bar2 +- apiVersion: company.com/v1 + field1: value1 + field2: true + field3: + - 1 + - 2 + field4: + a: true + b: false + kind: Bar + metadata: + creationTimestamp: "2017-02-13T00:50:10Z" + name: test2 + namespace: default + resourceVersion: "16955" + selfLink: /apis/company.com/v1/namespaces/default/bars/test2 + uid: 5ef5b446-f186-11e6-b041-acbc32c1ca87 +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/3.original b/pkg/cmd/edit/testdata/testcase-schemaless-list/3.original new file mode 100644 index 000000000..b4cd8a2bb --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/3.original @@ -0,0 +1,59 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +items: +- apiVersion: v1 + kind: Service + metadata: + creationTimestamp: "2017-02-12T20:11:19Z" + labels: + component: apiserver + provider: kubernetes + name: kubernetes + namespace: default + resourceVersion: "16953" + selfLink: /api/v1/namespaces/default/services/kubernetes + uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87 + spec: + clusterIP: 10.0.0.1 + ports: + - name: https + port: 443 + protocol: TCP + targetPort: 443 + sessionAffinity: ClientIP + type: ClusterIP + status: + loadBalancer: {} +- apiVersion: company.com/v1 + kind: Bar + metadata: + creationTimestamp: "2017-02-13T00:47:26Z" + name: test + namespace: default + resourceVersion: "16954" + selfLink: /apis/company.com/v1/namespaces/default/bars/test + uid: fd16c23d-f185-11e6-b041-acbc32c1ca87 + some-field: field1 + third-field: + sub-field: bar2 +- apiVersion: company.com/v1 + field1: value1 + field2: true + field3: + - 1 + field4: + a: true + b: false + kind: Bar + metadata: + creationTimestamp: "2017-02-13T00:50:10Z" + name: test2 + namespace: default + resourceVersion: "16955" + selfLink: /apis/company.com/v1/namespaces/default/bars/test2 + uid: 5ef5b446-f186-11e6-b041-acbc32c1ca87 +kind: List +metadata: {} diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/4.request b/pkg/cmd/edit/testdata/testcase-schemaless-list/4.request new file mode 100644 index 000000000..bd858120d --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/4.request @@ -0,0 +1,7 @@ +{ + "metadata": { + "labels": { + "new-label": "new-value" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/4.response b/pkg/cmd/edit/testdata/testcase-schemaless-list/4.response new file mode 100644 index 000000000..16d3eb5e9 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/4.response @@ -0,0 +1,33 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "kubernetes", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/kubernetes", + "uid": "6a8e8829-f15f-11e6-b041-acbc32c1ca87", + "resourceVersion": "17087", + "creationTimestamp": "2017-02-12T20:11:19Z", + "labels": { + "component": "apiserver", + "new-label": "new-value", + "provider": "kubernetes" + } + }, + "spec": { + "ports": [ + { + "name": "https", + "protocol": "TCP", + "port": 443, + "targetPort": 443 + } + ], + "clusterIP": "10.0.0.1", + "type": "ClusterIP", + "sessionAffinity": "ClientIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/5.request b/pkg/cmd/edit/testdata/testcase-schemaless-list/5.request new file mode 100644 index 000000000..5a74e33dd --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/5.request @@ -0,0 +1,3 @@ +{ + "other-field": "other-value" +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/5.response b/pkg/cmd/edit/testdata/testcase-schemaless-list/5.response new file mode 100644 index 000000000..5ebd7c6dc --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/5.response @@ -0,0 +1,17 @@ +{ + "apiVersion": "company.com/v1", + "kind": "Bar", + "metadata": { + "name": "test", + "namespace": "default", + "selfLink": "/apis/company.com/v1/namespaces/default/bars/test", + "uid": "fd16c23d-f185-11e6-b041-acbc32c1ca87", + "resourceVersion": "17088", + "creationTimestamp": "2017-02-13T00:47:26Z" + }, + "other-field": "other-value", + "some-field": "field1", + "third-field": { + "sub-field": "bar2" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/6.request b/pkg/cmd/edit/testdata/testcase-schemaless-list/6.request new file mode 100644 index 000000000..3c98e4fcf --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/6.request @@ -0,0 +1,6 @@ +{ + "field3": [ + 1, + 2 + ] +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/6.response b/pkg/cmd/edit/testdata/testcase-schemaless-list/6.response new file mode 100644 index 000000000..d9b94f9e6 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/6.response @@ -0,0 +1,22 @@ +{ + "apiVersion": "company.com/v1", + "field1": "value1", + "field2": true, + "field3": [ + 1, + 2 + ], + "field4": { + "a": true, + "b": false + }, + "kind": "Bar", + "metadata": { + "name": "test2", + "namespace": "default", + "selfLink": "/apis/company.com/v1/namespaces/default/bars/test2", + "uid": "5ef5b446-f186-11e6-b041-acbc32c1ca87", + "resourceVersion": "17089", + "creationTimestamp": "2017-02-13T00:50:10Z" + } +} diff --git a/pkg/cmd/edit/testdata/testcase-schemaless-list/test.yaml b/pkg/cmd/edit/testdata/testcase-schemaless-list/test.yaml new file mode 100644 index 000000000..726e74735 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-schemaless-list/test.yaml @@ -0,0 +1,55 @@ +description: edit a mix of schema and schemaless data +mode: edit +args: +- service/kubernetes +- bars/test +- bars/test2 +namespace: default +expectedStdout: +- "service/kubernetes edited" +- "bar.company.com/test edited" +- "bar.company.com/test2 edited" +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/default/services/kubernetes + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: request + expectedMethod: GET + expectedPath: /apis/company.com/v1/namespaces/default/bars/test + expectedInput: 1.request + resultingStatusCode: 200 + resultingOutput: 1.response +- type: request + expectedMethod: GET + expectedPath: /apis/company.com/v1/namespaces/default/bars/test2 + expectedInput: 2.request + resultingStatusCode: 200 + resultingOutput: 2.response +- type: edit + expectedInput: 3.original + resultingOutput: 3.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/default/services/kubernetes + expectedContentType: application/strategic-merge-patch+json + expectedInput: 4.request + resultingStatusCode: 200 + resultingOutput: 4.response +- type: request + expectedMethod: PATCH + expectedPath: /apis/company.com/v1/namespaces/default/bars/test + expectedContentType: application/merge-patch+json + expectedInput: 5.request + resultingStatusCode: 200 + resultingOutput: 5.response +- type: request + expectedMethod: PATCH + expectedPath: /apis/company.com/v1/namespaces/default/bars/test2 + expectedContentType: application/merge-patch+json + expectedInput: 6.request + resultingStatusCode: 200 + resultingOutput: 6.response diff --git a/pkg/cmd/edit/testdata/testcase-single-service/0.request b/pkg/cmd/edit/testdata/testcase-single-service/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-single-service/0.response b/pkg/cmd/edit/testdata/testcase-single-service/0.response new file mode 100644 index 000000000..2d45240d6 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-single-service/0.response @@ -0,0 +1,34 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "5f7da8db-e8c3-11e6-b7e2-acbc32c1ca87", + "resourceVersion": "20715", + "creationTimestamp": "2017-02-01T21:14:09Z", + "labels": { + "app": "svc1" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 80, + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "clusterIP": "10.0.0.146", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-single-service/1.edited b/pkg/cmd/edit/testdata/testcase-single-service/1.edited new file mode 100644 index 000000000..f2b134ab6 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-single-service/1.edited @@ -0,0 +1,29 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-01T21:14:09Z" + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: edit-test + resourceVersion: "20715" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 5f7da8db-e8c3-11e6-b7e2-acbc32c1ca87 +spec: + clusterIP: 10.0.0.146 + ports: + - name: "80" + port: 81 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-single-service/1.original b/pkg/cmd/edit/testdata/testcase-single-service/1.original new file mode 100644 index 000000000..ee8925d88 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-single-service/1.original @@ -0,0 +1,28 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-01T21:14:09Z" + labels: + app: svc1 + name: svc1 + namespace: edit-test + resourceVersion: "20715" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: 5f7da8db-e8c3-11e6-b7e2-acbc32c1ca87 +spec: + clusterIP: 10.0.0.146 + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-single-service/2.request b/pkg/cmd/edit/testdata/testcase-single-service/2.request new file mode 100644 index 000000000..a69f19ac2 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-single-service/2.request @@ -0,0 +1,26 @@ +{ + "metadata": { + "labels": { + "new-label": "new-value" + } + }, + "spec": { + "$setElementOrder/ports": [ + { + "port": 81 + } + ], + "ports": [ + { + "name": "80", + "port": 81, + "protocol": "TCP", + "targetPort": 80 + }, + { + "$patch": "delete", + "port": 80 + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-single-service/2.response b/pkg/cmd/edit/testdata/testcase-single-service/2.response new file mode 100644 index 000000000..4fb08268e --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-single-service/2.response @@ -0,0 +1,35 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "5f7da8db-e8c3-11e6-b7e2-acbc32c1ca87", + "resourceVersion": "20820", + "creationTimestamp": "2017-02-01T21:14:09Z", + "labels": { + "app": "svc1", + "new-label": "new-value" + } + }, + "spec": { + "ports": [ + { + "name": "80", + "protocol": "TCP", + "port": 81, + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "clusterIP": "10.0.0.146", + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-single-service/test.yaml b/pkg/cmd/edit/testdata/testcase-single-service/test.yaml new file mode 100644 index 000000000..f1f66de23 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-single-service/test.yaml @@ -0,0 +1,29 @@ +# kubectl create namespace edit-test +# kubectl create service clusterip svc1 --tcp 80 --namespace=edit-test +# kubectl edit service svc1 --namespace=edit-test +description: edit a single service, add a label and change a port +mode: edit +args: +- service +- svc1 +namespace: edit-test +expectedStdout: +- service/svc1 edited +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 2.request + resultingStatusCode: 200 + resultingOutput: 2.response diff --git a/pkg/cmd/edit/testdata/testcase-syntax-error/0.request b/pkg/cmd/edit/testdata/testcase-syntax-error/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-syntax-error/0.response b/pkg/cmd/edit/testdata/testcase-syntax-error/0.response new file mode 100644 index 000000000..6a6a6ac70 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-syntax-error/0.response @@ -0,0 +1,32 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "kubernetes", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/kubernetes", + "uid": "6a8e8829-f15f-11e6-b041-acbc32c1ca87", + "resourceVersion": "8", + "creationTimestamp": "2017-02-12T20:11:19Z", + "labels": { + "component": "apiserver", + "provider": "kubernetes" + } + }, + "spec": { + "ports": [ + { + "name": "https", + "protocol": "TCP", + "port": 443, + "targetPort": 443 + } + ], + "clusterIP": "10.0.0.1", + "type": "ClusterIP", + "sessionAffinity": "ClientIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-syntax-error/1.edited b/pkg/cmd/edit/testdata/testcase-syntax-error/1.edited new file mode 100644 index 000000000..30d3859a3 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-syntax-error/1.edited @@ -0,0 +1,27 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-12T20:11:19Z" + labels: + component: apiserver + provider: kubernetes + name: kubernetes + namespace: default + resourceVersion: "8" + selfLink: /api/v1/namespaces/default/services/kubernetes + uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87 +spec + clusterIP: 10.0.0.1 + ports: + name: https + port: 443 + protocol: TCP + targetPort: 443 + sessionAffinity: ClientIP + type: ClusterIP +status: + loadBalancer: { diff --git a/pkg/cmd/edit/testdata/testcase-syntax-error/1.original b/pkg/cmd/edit/testdata/testcase-syntax-error/1.original new file mode 100644 index 000000000..ce6c5ce35 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-syntax-error/1.original @@ -0,0 +1,27 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-12T20:11:19Z" + labels: + component: apiserver + provider: kubernetes + name: kubernetes + namespace: default + resourceVersion: "8" + selfLink: /api/v1/namespaces/default/services/kubernetes + uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87 +spec: + clusterIP: 10.0.0.1 + ports: + - name: https + port: 443 + protocol: TCP + targetPort: 443 + sessionAffinity: ClientIP + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-syntax-error/2.edited b/pkg/cmd/edit/testdata/testcase-syntax-error/2.edited new file mode 100644 index 000000000..7d6945abb --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-syntax-error/2.edited @@ -0,0 +1,30 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +# The edited file had a syntax error: error converting YAML to JSON: yaml: line 17: could not find expected ':' +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-12T20:11:19Z" + labels: + component: apiserver + provider: kubernetes + new-label: foo + name: kubernetes + namespace: default + resourceVersion: "8" + selfLink: /api/v1/namespaces/default/services/kubernetes + uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87 +spec: + clusterIP: 10.0.0.1 + ports: + - name: https + port: 443 + protocol: TCP + targetPort: 443 + sessionAffinity: ClientIP + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-syntax-error/2.original b/pkg/cmd/edit/testdata/testcase-syntax-error/2.original new file mode 100644 index 000000000..7cae61116 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-syntax-error/2.original @@ -0,0 +1,29 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +# The edited file had a syntax error: error parsing edited-file: error converting YAML to JSON: yaml: line 18: could not find expected ':' +# +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2017-02-12T20:11:19Z" + labels: + component: apiserver + provider: kubernetes + name: kubernetes + namespace: default + resourceVersion: "8" + selfLink: /api/v1/namespaces/default/services/kubernetes + uid: 6a8e8829-f15f-11e6-b041-acbc32c1ca87 +spec + clusterIP: 10.0.0.1 + ports: + name: https + port: 443 + protocol: TCP + targetPort: 443 + sessionAffinity: ClientIP + type: ClusterIP +status: + loadBalancer: { diff --git a/pkg/cmd/edit/testdata/testcase-syntax-error/3.request b/pkg/cmd/edit/testdata/testcase-syntax-error/3.request new file mode 100644 index 000000000..f596ebf70 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-syntax-error/3.request @@ -0,0 +1,7 @@ +{ + "metadata": { + "labels": { + "new-label": "foo" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-syntax-error/3.response b/pkg/cmd/edit/testdata/testcase-syntax-error/3.response new file mode 100644 index 000000000..e2d694276 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-syntax-error/3.response @@ -0,0 +1,33 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "kubernetes", + "namespace": "default", + "selfLink": "/api/v1/namespaces/default/services/kubernetes", + "uid": "6a8e8829-f15f-11e6-b041-acbc32c1ca87", + "resourceVersion": "1174", + "creationTimestamp": "2017-02-12T20:11:19Z", + "labels": { + "component": "apiserver", + "new-label": "foo", + "provider": "kubernetes" + } + }, + "spec": { + "ports": [ + { + "name": "https", + "protocol": "TCP", + "port": 443, + "targetPort": 443 + } + ], + "clusterIP": "10.0.0.1", + "type": "ClusterIP", + "sessionAffinity": "ClientIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-syntax-error/test.yaml b/pkg/cmd/edit/testdata/testcase-syntax-error/test.yaml new file mode 100644 index 000000000..636557139 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-syntax-error/test.yaml @@ -0,0 +1,28 @@ +description: edit with a syntax error, then re-edit and save +mode: edit +args: +- service/kubernetes +namespace: default +expectedStdout: +- "service/kubernetes edited" +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/default/services/kubernetes + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: edit + expectedInput: 2.original + resultingOutput: 2.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/default/services/kubernetes + expectedContentType: application/strategic-merge-patch+json + expectedInput: 3.request + resultingStatusCode: 200 + resultingOutput: 3.response diff --git a/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/0.request b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/0.response b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/0.response new file mode 100644 index 000000000..86205eb3a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/0.response @@ -0,0 +1,25 @@ +{ + "kind": "StorageClass", + "apiVersion": "storage.k8s.io/v1beta1", + "metadata": { + "name": "foo", + "selfLink": "/apis/storage.k8s.io/v1beta1/storageclassesfoo", + "uid": "b2287558-f190-11e6-b041-acbc32c1ca87", + "resourceVersion": "21388", + "creationTimestamp": "2017-02-13T02:04:04Z", + "labels": { + "label1": "value1" + } + }, + "provisioner": "foo", + "parameters": { + "baz": "qux", + "foo": "bar" + }, + "unknownServerField1": { + "data": true + }, + "unknownServerField2": { + "data": true + } +} diff --git a/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/1.edited b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/1.edited new file mode 100644 index 000000000..3859fe06a --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/1.edited @@ -0,0 +1,23 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: storage.k8s.io/v1beta1 +kind: StorageClass +metadata: + creationTimestamp: "2017-02-13T02:04:04Z" + labels: + label1: value1 + label2: value2 + name: foo + resourceVersion: "21388" + selfLink: /apis/storage.k8s.io/v1beta1/storageclassesfoo + uid: b2287558-f190-11e6-b041-acbc32c1ca87 +parameters: + baz: qux + foo: bar +provisioner: foo +unknownClientField: + clientdata: true +unknownServerField1: + data: true diff --git a/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/1.original b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/1.original new file mode 100644 index 000000000..d3a4c5a42 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/1.original @@ -0,0 +1,22 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: storage.k8s.io/v1beta1 +kind: StorageClass +metadata: + creationTimestamp: "2017-02-13T02:04:04Z" + labels: + label1: value1 + name: foo + resourceVersion: "21388" + selfLink: /apis/storage.k8s.io/v1beta1/storageclassesfoo + uid: b2287558-f190-11e6-b041-acbc32c1ca87 +parameters: + baz: qux + foo: bar +provisioner: foo +unknownServerField1: + data: true +unknownServerField2: + data: true diff --git a/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/2.request b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/2.request new file mode 100644 index 000000000..5d79cb5c3 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/2.request @@ -0,0 +1,11 @@ +{ + "metadata": { + "labels": { + "label2": "value2" + } + }, + "unknownClientField": { + "clientdata": true + }, + "unknownServerField2": null +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/2.response b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/2.response new file mode 100644 index 000000000..a61eab5a7 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/2.response @@ -0,0 +1,26 @@ +{ + "kind": "StorageClass", + "apiVersion": "storage.k8s.io/v1beta1", + "metadata": { + "name": "foo", + "selfLink": "/apis/storage.k8s.io/v1beta1/storageclassesfoo", + "uid": "b2287558-f190-11e6-b041-acbc32c1ca87", + "resourceVersion": "21431", + "creationTimestamp": "2017-02-13T02:04:04Z", + "labels": { + "label1": "value1", + "label2": "value2" + } + }, + "provisioner": "foo", + "parameters": { + "baz": "qux", + "foo": "bar" + }, + "unknownClientField": { + "clientdata": true + }, + "unknownServerField1": { + "data": true + } +} diff --git a/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/test.yaml b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/test.yaml new file mode 100644 index 000000000..5b119e611 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-field-known-group-kind/test.yaml @@ -0,0 +1,25 @@ +description: edit an unknown version of a known group/kind +mode: edit +args: +- storageclasses.v1beta1.storage.k8s.io/foo +namespace: default +expectedStdout: +- "storageclass.storage.k8s.io/foo edited" +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /apis/storage.k8s.io/v1beta1/storageclasses/foo + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: request + expectedMethod: PATCH + expectedPath: /apis/storage.k8s.io/v1beta1/storageclasses/foo + expectedContentType: application/strategic-merge-patch+json + expectedInput: 2.request + resultingStatusCode: 200 + resultingOutput: 2.response diff --git a/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/0.request b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/0.response b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/0.response new file mode 100644 index 000000000..54ea661e8 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/0.response @@ -0,0 +1,22 @@ +{ + "kind": "StorageClass", + "apiVersion": "storage.k8s.io/v0", + "metadata": { + "name": "foo", + "selfLink": "/apis/storage.k8s.io/v0/storageclassesfoo", + "uid": "b2287558-f190-11e6-b041-acbc32c1ca87", + "resourceVersion": "21388", + "creationTimestamp": "2017-02-13T02:04:04Z", + "labels": { + "label1": "value1" + } + }, + "provisioner": "foo", + "parameters": { + "baz": "qux", + "foo": "bar" + }, + "extraField": { + "otherData": true + } +} diff --git a/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/1.edited b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/1.edited new file mode 100644 index 000000000..f25e1bad8 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/1.edited @@ -0,0 +1,22 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: storage.k8s.io/v0 +extraField: + otherData: true + addedData: "foo" +kind: StorageClass +metadata: + creationTimestamp: "2017-02-13T02:04:04Z" + labels: + label1: value1 + label2: value2 + name: foo + resourceVersion: "21388" + selfLink: /apis/storage.k8s.io/v0/storageclassesfoo + uid: b2287558-f190-11e6-b041-acbc32c1ca87 +parameters: + baz: qux + foo: bar +provisioner: foo diff --git a/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/1.original b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/1.original new file mode 100644 index 000000000..a5cffc14e --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/1.original @@ -0,0 +1,20 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: storage.k8s.io/v0 +extraField: + otherData: true +kind: StorageClass +metadata: + creationTimestamp: "2017-02-13T02:04:04Z" + labels: + label1: value1 + name: foo + resourceVersion: "21388" + selfLink: /apis/storage.k8s.io/v0/storageclassesfoo + uid: b2287558-f190-11e6-b041-acbc32c1ca87 +parameters: + baz: qux + foo: bar +provisioner: foo diff --git a/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/2.request b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/2.request new file mode 100644 index 000000000..b87c5cbb9 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/2.request @@ -0,0 +1,10 @@ +{ + "extraField": { + "addedData": "foo" + }, + "metadata": { + "labels": { + "label2": "value2" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/2.response b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/2.response new file mode 100644 index 000000000..143c7fe2f --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/2.response @@ -0,0 +1,24 @@ +{ + "kind": "StorageClass", + "apiVersion": "storage.k8s.io/v0", + "metadata": { + "name": "foo", + "selfLink": "/apis/storage.k8s.io/v0/storageclassesfoo", + "uid": "b2287558-f190-11e6-b041-acbc32c1ca87", + "resourceVersion": "21431", + "creationTimestamp": "2017-02-13T02:04:04Z", + "labels": { + "label1": "value1", + "label2": "value2" + } + }, + "provisioner": "foo", + "parameters": { + "baz": "qux", + "foo": "bar" + }, + "extraField": { + "otherData": true, + "addedData": true + } +} diff --git a/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/test.yaml b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/test.yaml new file mode 100644 index 000000000..b0c78e05c --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-unknown-version-known-group-kind/test.yaml @@ -0,0 +1,25 @@ +description: edit an unknown version of a known group/kind +mode: edit +args: +- storageclasses.v0.storage.k8s.io/foo +namespace: default +expectedStdout: +- "storageclass.storage.k8s.io/foo edited" +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /apis/storage.k8s.io/v0/storageclasses/foo + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: request + expectedMethod: PATCH + expectedPath: /apis/storage.k8s.io/v0/storageclasses/foo + expectedContentType: application/merge-patch+json + expectedInput: 2.request + resultingStatusCode: 200 + resultingOutput: 2.response diff --git a/pkg/cmd/edit/testdata/testcase-update-annotation/0.request b/pkg/cmd/edit/testdata/testcase-update-annotation/0.request new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/cmd/edit/testdata/testcase-update-annotation/0.response b/pkg/cmd/edit/testdata/testcase-update-annotation/0.response new file mode 100644 index 000000000..b4693de49 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-update-annotation/0.response @@ -0,0 +1,37 @@ +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"kind\":\"Service\",\"apiVersion\":\"v1\",\"metadata\":{\"name\":\"svc1\",\"creationTimestamp\":null,\"labels\":{\"app\":\"svc1\"}},\"spec\":{\"ports\":[{\"name\":\"80\",\"protocol\":\"TCP\",\"port\":80,\"targetPort\":80}],\"selector\":{\"app\":\"svc1\"},\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + }, + "creationTimestamp": "2017-02-27T19:40:53Z", + "labels": { + "app": "svc1" + }, + "name": "svc1", + "namespace": "edit-test", + "resourceVersion": "670", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "a6c11186-fd24-11e6-b53c-480fcf4a5275" + }, + "spec": { + "clusterIP": "10.0.0.204", + "ports": [ + { + "name": "80", + "port": 80, + "protocol": "TCP", + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-update-annotation/1.edited b/pkg/cmd/edit/testdata/testcase-update-annotation/1.edited new file mode 100644 index 000000000..51de60e11 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-update-annotation/1.edited @@ -0,0 +1,32 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"kind":"Service","apiVersion":"v1","metadata":{"name":"svc1","creationTimestamp":null,"labels":{"app":"svc1"}},"spec":{"ports":[{"name":"80","protocol":"TCP","port":80,"targetPort":80}],"selector":{"app":"svc1"},"type":"ClusterIP"},"status":{"loadBalancer":{}}} + creationTimestamp: "2017-02-27T19:40:53Z" + labels: + app: svc1 + new-label: new-value + name: svc1 + namespace: edit-test + resourceVersion: "670" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: a6c11186-fd24-11e6-b53c-480fcf4a5275 +spec: + clusterIP: 10.0.0.204 + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-update-annotation/1.original b/pkg/cmd/edit/testdata/testcase-update-annotation/1.original new file mode 100644 index 000000000..38d71f247 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-update-annotation/1.original @@ -0,0 +1,31 @@ +# Please edit the object below. Lines beginning with a '#' will be ignored, +# and an empty file will abort the edit. If an error occurs while saving this file will be +# reopened with the relevant failures. +# +apiVersion: v1 +kind: Service +metadata: + annotations: + kubectl.kubernetes.io/last-applied-configuration: | + {"kind":"Service","apiVersion":"v1","metadata":{"name":"svc1","creationTimestamp":null,"labels":{"app":"svc1"}},"spec":{"ports":[{"name":"80","protocol":"TCP","port":80,"targetPort":80}],"selector":{"app":"svc1"},"type":"ClusterIP"},"status":{"loadBalancer":{}}} + creationTimestamp: "2017-02-27T19:40:53Z" + labels: + app: svc1 + name: svc1 + namespace: edit-test + resourceVersion: "670" + selfLink: /api/v1/namespaces/edit-test/services/svc1 + uid: a6c11186-fd24-11e6-b53c-480fcf4a5275 +spec: + clusterIP: 10.0.0.204 + ports: + - name: "80" + port: 80 + protocol: TCP + targetPort: 80 + selector: + app: svc1 + sessionAffinity: None + type: ClusterIP +status: + loadBalancer: {} diff --git a/pkg/cmd/edit/testdata/testcase-update-annotation/2.request b/pkg/cmd/edit/testdata/testcase-update-annotation/2.request new file mode 100644 index 000000000..74d44f61e --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-update-annotation/2.request @@ -0,0 +1,10 @@ +{ + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"creationTimestamp\":\"2017-02-27T19:40:53Z\",\"labels\":{\"app\":\"svc1\",\"new-label\":\"new-value\"},\"name\":\"svc1\",\"namespace\":\"edit-test\",\"resourceVersion\":\"670\",\"selfLink\":\"/api/v1/namespaces/edit-test/services/svc1\",\"uid\":\"a6c11186-fd24-11e6-b53c-480fcf4a5275\"},\"spec\":{\"clusterIP\":\"10.0.0.204\",\"ports\":[{\"name\":\"80\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":80}],\"selector\":{\"app\":\"svc1\"},\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + }, + "labels": { + "new-label": "new-value" + } + } +} \ No newline at end of file diff --git a/pkg/cmd/edit/testdata/testcase-update-annotation/2.response b/pkg/cmd/edit/testdata/testcase-update-annotation/2.response new file mode 100644 index 000000000..cf658cb8d --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-update-annotation/2.response @@ -0,0 +1,38 @@ +{ + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"creationTimestamp\":\"2017-02-27T19:40:53Z\",\"labels\":{\"app\":\"svc1\",\"new-label\":\"new-value\"},\"name\":\"svc1\",\"namespace\":\"edit-test\",\"resourceVersion\":\"670\",\"selfLink\":\"/api/v1/namespaces/edit-test/services/svc1\",\"uid\":\"a6c11186-fd24-11e6-b53c-480fcf4a5275\"},\"spec\":{\"clusterIP\":\"10.0.0.204\",\"ports\":[{\"name\":\"80\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":80}],\"selector\":{\"app\":\"svc1\"},\"sessionAffinity\":\"None\",\"type\":\"ClusterIP\"},\"status\":{\"loadBalancer\":{}}}\n" + }, + "name": "svc1", + "namespace": "edit-test", + "selfLink": "/api/v1/namespaces/edit-test/services/svc1", + "uid": "a6c11186-fd24-11e6-b53c-480fcf4a5275", + "resourceVersion":"1045", + "creationTimestamp":"2017-02-27T19:40:53Z", + "labels": { + "app": "svc1", + "new-label": "new-value" + } + }, + "spec": { + "clusterIP": "10.0.0.204", + "ports": [ + { + "name": "80", + "port": 80, + "protocol": "TCP", + "targetPort": 80 + } + ], + "selector": { + "app": "svc1" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } +} diff --git a/pkg/cmd/edit/testdata/testcase-update-annotation/test.yaml b/pkg/cmd/edit/testdata/testcase-update-annotation/test.yaml new file mode 100644 index 000000000..1e01c8369 --- /dev/null +++ b/pkg/cmd/edit/testdata/testcase-update-annotation/test.yaml @@ -0,0 +1,30 @@ +# kubectl create namespace edit-test +# kubectl create service clusterip svc1 --tcp 80 --namespace=edit-test --save-config +# kubectl edit service svc1 --namespace=edit-test --save-config=true +description: edit with flag --save-config=true should update the annotation +mode: edit +args: +- service +- svc1 +saveConfig: "true" +namespace: edit-test +expectedStdout: +- service/svc1 edited +expectedExitCode: 0 +steps: +- type: request + expectedMethod: GET + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedInput: 0.request + resultingStatusCode: 200 + resultingOutput: 0.response +- type: edit + expectedInput: 1.original + resultingOutput: 1.edited +- type: request + expectedMethod: PATCH + expectedPath: /api/v1/namespaces/edit-test/services/svc1 + expectedContentType: application/strategic-merge-patch+json + expectedInput: 2.request + resultingStatusCode: 200 + resultingOutput: 2.response diff --git a/pkg/cmd/exec/exec.go b/pkg/cmd/exec/exec.go new file mode 100644 index 000000000..b70e3ea64 --- /dev/null +++ b/pkg/cmd/exec/exec.go @@ -0,0 +1,367 @@ +/* +Copyright 2014 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 exec + +import ( + "fmt" + "io" + "net/url" + "time" + + dockerterm "github.com/docker/docker/pkg/term" + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + coreclient "k8s.io/client-go/kubernetes/typed/core/v1" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/interrupt" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubectl/pkg/util/term" +) + +var ( + execExample = templates.Examples(i18n.T(` + # Get output from running 'date' command from pod mypod, using the first container by default + kubectl exec mypod date + + # Get output from running 'date' command in ruby-container from pod mypod + kubectl exec mypod -c ruby-container date + + # Switch to raw terminal mode, sends stdin to 'bash' in ruby-container from pod mypod + # and sends stdout/stderr from 'bash' back to the client + kubectl exec mypod -c ruby-container -i -t -- bash -il + + # List contents of /usr from the first container of pod mypod and sort by modification time. + # If the command you want to execute in the pod has any flags in common (e.g. -i), + # you must use two dashes (--) to separate your command's flags/arguments. + # Also note, do not surround your command and its flags/arguments with quotes + # unless that is how you would execute it normally (i.e., do ls -t /usr, not "ls -t /usr"). + kubectl exec mypod -i -t -- ls -t /usr + + # Get output from running 'date' command from the first pod of the deployment mydeployment, using the first container by default + kubectl exec deploy/mydeployment date + + # Get output from running 'date' command from the first pod of the service myservice, using the first container by default + kubectl exec svc/myservice date + `)) +) + +const ( + execUsageStr = "expected 'exec (POD | TYPE/NAME) COMMAND [ARG1] [ARG2] ... [ARGN]'.\nPOD or TYPE/NAME and COMMAND are required arguments for the exec command" + defaultPodExecTimeout = 60 * time.Second +) + +func NewCmdExec(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + options := &ExecOptions{ + StreamOptions: StreamOptions{ + IOStreams: streams, + }, + + Executor: &DefaultRemoteExecutor{}, + } + cmd := &cobra.Command{ + Use: "exec (POD | TYPE/NAME) [-c CONTAINER] [flags] -- COMMAND [args...]", + DisableFlagsInUseLine: true, + Short: i18n.T("Execute a command in a container"), + Long: "Execute a command in a container.", + Example: execExample, + Run: func(cmd *cobra.Command, args []string) { + argsLenAtDash := cmd.ArgsLenAtDash() + cmdutil.CheckErr(options.Complete(f, cmd, args, argsLenAtDash)) + cmdutil.CheckErr(options.Validate()) + cmdutil.CheckErr(options.Run()) + }, + } + cmdutil.AddPodRunningTimeoutFlag(cmd, defaultPodExecTimeout) + // TODO support UID + cmd.Flags().StringVarP(&options.ContainerName, "container", "c", options.ContainerName, "Container name. If omitted, the first container in the pod will be chosen") + cmd.Flags().BoolVarP(&options.Stdin, "stdin", "i", options.Stdin, "Pass stdin to the container") + cmd.Flags().BoolVarP(&options.TTY, "tty", "t", options.TTY, "Stdin is a TTY") + return cmd +} + +// RemoteExecutor defines the interface accepted by the Exec command - provided for test stubbing +type RemoteExecutor interface { + Execute(method string, url *url.URL, config *restclient.Config, stdin io.Reader, stdout, stderr io.Writer, tty bool, terminalSizeQueue remotecommand.TerminalSizeQueue) error +} + +// DefaultRemoteExecutor is the standard implementation of remote command execution +type DefaultRemoteExecutor struct{} + +func (*DefaultRemoteExecutor) Execute(method string, url *url.URL, config *restclient.Config, stdin io.Reader, stdout, stderr io.Writer, tty bool, terminalSizeQueue remotecommand.TerminalSizeQueue) error { + exec, err := remotecommand.NewSPDYExecutor(config, method, url) + if err != nil { + return err + } + return exec.Stream(remotecommand.StreamOptions{ + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + Tty: tty, + TerminalSizeQueue: terminalSizeQueue, + }) +} + +type StreamOptions struct { + Namespace string + PodName string + ContainerName string + Stdin bool + TTY bool + // minimize unnecessary output + Quiet bool + // InterruptParent, if set, is used to handle interrupts while attached + InterruptParent *interrupt.Handler + + genericclioptions.IOStreams + + // for testing + overrideStreams func() (io.ReadCloser, io.Writer, io.Writer) + isTerminalIn func(t term.TTY) bool +} + +// ExecOptions declare the arguments accepted by the Exec command +type ExecOptions struct { + StreamOptions + + ResourceName string + Command []string + + ParentCommandName string + EnableSuggestedCmdUsage bool + + Builder func() *resource.Builder + ExecutablePodFn polymorphichelpers.AttachablePodForObjectFunc + restClientGetter genericclioptions.RESTClientGetter + + Pod *corev1.Pod + Executor RemoteExecutor + PodClient coreclient.PodsGetter + GetPodTimeout time.Duration + Config *restclient.Config +} + +// Complete verifies command line arguments and loads data from the command environment +func (p *ExecOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, argsIn []string, argsLenAtDash int) error { + // Let kubectl exec follow rules for `--`, see #13004 issue + if len(argsIn) == 0 || argsLenAtDash == 0 { + return cmdutil.UsageErrorf(cmd, execUsageStr) + } + + p.ResourceName = argsIn[0] + p.Command = argsIn[1:] + + var err error + + p.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + p.ExecutablePodFn = polymorphichelpers.AttachablePodForObjectFn + + p.GetPodTimeout, err = cmdutil.GetPodRunningTimeoutFlag(cmd) + if err != nil { + return cmdutil.UsageErrorf(cmd, err.Error()) + } + + p.Builder = f.NewBuilder + p.restClientGetter = f + + cmdParent := cmd.Parent() + if cmdParent != nil { + p.ParentCommandName = cmdParent.CommandPath() + } + if len(p.ParentCommandName) > 0 && cmdutil.IsSiblingCommandExists(cmd, "describe") { + p.EnableSuggestedCmdUsage = true + } + + p.Config, err = f.ToRESTConfig() + if err != nil { + return err + } + + clientset, err := f.KubernetesClientSet() + if err != nil { + return err + } + p.PodClient = clientset.CoreV1() + + return nil +} + +// Validate checks that the provided exec options are specified. +func (p *ExecOptions) Validate() error { + if len(p.PodName) == 0 && len(p.ResourceName) == 0 { + return fmt.Errorf("pod or type/name must be specified") + } + if len(p.Command) == 0 { + return fmt.Errorf("you must specify at least one command for the container") + } + if p.Out == nil || p.ErrOut == nil { + return fmt.Errorf("both output and error output must be provided") + } + return nil +} + +func (o *StreamOptions) SetupTTY() term.TTY { + t := term.TTY{ + Parent: o.InterruptParent, + Out: o.Out, + } + + if !o.Stdin { + // need to nil out o.In to make sure we don't create a stream for stdin + o.In = nil + o.TTY = false + return t + } + + t.In = o.In + if !o.TTY { + return t + } + + if o.isTerminalIn == nil { + o.isTerminalIn = func(tty term.TTY) bool { + return tty.IsTerminalIn() + } + } + if !o.isTerminalIn(t) { + o.TTY = false + + if o.ErrOut != nil { + fmt.Fprintln(o.ErrOut, "Unable to use a TTY - input is not a terminal or the right kind of file") + } + + return t + } + + // if we get to here, the user wants to attach stdin, wants a TTY, and o.In is a terminal, so we + // can safely set t.Raw to true + t.Raw = true + + if o.overrideStreams == nil { + // use dockerterm.StdStreams() to get the right I/O handles on Windows + o.overrideStreams = dockerterm.StdStreams + } + stdin, stdout, _ := o.overrideStreams() + o.In = stdin + t.In = stdin + if o.Out != nil { + o.Out = stdout + t.Out = stdout + } + + return t +} + +// Run executes a validated remote execution against a pod. +func (p *ExecOptions) Run() error { + var err error + // we still need legacy pod getter when PodName in ExecOptions struct is provided, + // since there are any other command run this function by providing Podname with PodsGetter + // and without resource builder, eg: `kubectl cp`. + if len(p.PodName) != 0 { + p.Pod, err = p.PodClient.Pods(p.Namespace).Get(p.PodName, metav1.GetOptions{}) + if err != nil { + return err + } + } else { + builder := p.Builder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(p.Namespace).DefaultNamespace().ResourceNames("pods", p.ResourceName) + + obj, err := builder.Do().Object() + if err != nil { + return err + } + + p.Pod, err = p.ExecutablePodFn(p.restClientGetter, obj, p.GetPodTimeout) + if err != nil { + return err + } + } + + pod := p.Pod + + if pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed { + return fmt.Errorf("cannot exec into a container in a completed pod; current phase is %s", pod.Status.Phase) + } + + containerName := p.ContainerName + if len(containerName) == 0 { + if len(pod.Spec.Containers) > 1 { + fmt.Fprintf(p.ErrOut, "Defaulting container name to %s.\n", pod.Spec.Containers[0].Name) + if p.EnableSuggestedCmdUsage { + fmt.Fprintf(p.ErrOut, "Use '%s describe pod/%s -n %s' to see all of the containers in this pod.\n", p.ParentCommandName, pod.Name, p.Namespace) + } + } + containerName = pod.Spec.Containers[0].Name + } + + // ensure we can recover the terminal while attached + t := p.SetupTTY() + + var sizeQueue remotecommand.TerminalSizeQueue + if t.Raw { + // this call spawns a goroutine to monitor/update the terminal size + sizeQueue = t.MonitorSize(t.GetSize()) + + // unset p.Err if it was previously set because both stdout and stderr go over p.Out when tty is + // true + p.ErrOut = nil + } + + fn := func() error { + restClient, err := restclient.RESTClientFor(p.Config) + if err != nil { + return err + } + + // TODO: consider abstracting into a client invocation or client helper + req := restClient.Post(). + Resource("pods"). + Name(pod.Name). + Namespace(pod.Namespace). + SubResource("exec") + req.VersionedParams(&corev1.PodExecOptions{ + Container: containerName, + Command: p.Command, + Stdin: p.Stdin, + Stdout: p.Out != nil, + Stderr: p.ErrOut != nil, + TTY: t.Raw, + }, scheme.ParameterCodec) + + return p.Executor.Execute("POST", req.URL(), p.Config, p.In, p.Out, p.ErrOut, t.Raw, sizeQueue) + } + + if err := t.Safe(fn); err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/exec/exec_test.go b/pkg/cmd/exec/exec_test.go new file mode 100644 index 000000000..9f894c565 --- /dev/null +++ b/pkg/cmd/exec/exec_test.go @@ -0,0 +1,407 @@ +/* +Copyright 2014 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 exec + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "reflect" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/client-go/tools/remotecommand" + + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/term" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +type fakeRemoteExecutor struct { + method string + url *url.URL + execErr error +} + +func (f *fakeRemoteExecutor) Execute(method string, url *url.URL, config *restclient.Config, stdin io.Reader, stdout, stderr io.Writer, tty bool, terminalSizeQueue remotecommand.TerminalSizeQueue) error { + f.method = method + f.url = url + return f.execErr +} + +func TestPodAndContainer(t *testing.T) { + tests := []struct { + args []string + argsLenAtDash int + p *ExecOptions + name string + expectError bool + expectedPod string + expectedContainer string + expectedArgs []string + obj *corev1.Pod + }{ + { + p: &ExecOptions{}, + argsLenAtDash: -1, + expectError: true, + name: "empty", + }, + { + p: &ExecOptions{}, + argsLenAtDash: -1, + expectError: true, + name: "no cmd", + obj: execPod(), + }, + { + p: &ExecOptions{StreamOptions: StreamOptions{ContainerName: "bar"}}, + argsLenAtDash: -1, + expectError: true, + name: "no cmd, w/ container", + obj: execPod(), + }, + { + p: &ExecOptions{}, + args: []string{"foo", "cmd"}, + argsLenAtDash: 0, + expectError: true, + name: "no pod, pod name is behind dash", + obj: execPod(), + }, + { + p: &ExecOptions{}, + args: []string{"foo"}, + argsLenAtDash: -1, + expectError: true, + name: "no cmd, w/o flags", + obj: execPod(), + }, + { + p: &ExecOptions{}, + args: []string{"foo", "cmd"}, + argsLenAtDash: -1, + expectedPod: "foo", + expectedArgs: []string{"cmd"}, + name: "cmd, w/o flags", + obj: execPod(), + }, + { + p: &ExecOptions{}, + args: []string{"foo", "cmd"}, + argsLenAtDash: 1, + expectedPod: "foo", + expectedArgs: []string{"cmd"}, + name: "cmd, cmd is behind dash", + obj: execPod(), + }, + { + p: &ExecOptions{StreamOptions: StreamOptions{ContainerName: "bar"}}, + args: []string{"foo", "cmd"}, + argsLenAtDash: -1, + expectedPod: "foo", + expectedContainer: "bar", + expectedArgs: []string{"cmd"}, + name: "cmd, container in flag", + obj: execPod(), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var err error + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { return nil, nil }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + cmd := NewCmdExec(tf, genericclioptions.NewTestIOStreamsDiscard()) + options := test.p + options.ErrOut = bytes.NewBuffer([]byte{}) + options.Out = bytes.NewBuffer([]byte{}) + err = options.Complete(tf, cmd, test.args, test.argsLenAtDash) + err = options.Validate() + + if test.expectError && err == nil { + t.Errorf("%s: unexpected non-error", test.name) + } + if !test.expectError && err != nil { + t.Errorf("%s: unexpected error: %v", test.name, err) + } + if err != nil { + return + } + + pod, err := options.ExecutablePodFn(tf, test.obj, defaultPodExecTimeout) + if pod.Name != test.expectedPod { + t.Errorf("%s: expected: %s, got: %s", test.name, test.expectedPod, options.PodName) + } + if options.ContainerName != test.expectedContainer { + t.Errorf("%s: expected: %s, got: %s", test.name, test.expectedContainer, options.ContainerName) + } + if !reflect.DeepEqual(test.expectedArgs, options.Command) { + t.Errorf("%s: expected: %v, got %v", test.name, test.expectedArgs, options.Command) + } + }) + } +} + +func TestExec(t *testing.T) { + version := "v1" + tests := []struct { + name, version, podPath, fetchPodPath, execPath string + pod *corev1.Pod + execErr bool + }{ + { + name: "pod exec", + version: version, + podPath: "/api/" + version + "/namespaces/test/pods/foo", + fetchPodPath: "/namespaces/test/pods/foo", + execPath: "/api/" + version + "/namespaces/test/pods/foo/exec", + pod: execPod(), + }, + { + name: "pod exec error", + version: version, + podPath: "/api/" + version + "/namespaces/test/pods/foo", + fetchPodPath: "/namespaces/test/pods/foo", + execPath: "/api/" + version + "/namespaces/test/pods/foo/exec", + pod: execPod(), + execErr: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == test.podPath && m == "GET": + body := cmdtesting.ObjBody(codec, test.pod) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + case p == test.fetchPodPath && m == "GET": + body := cmdtesting.ObjBody(codec, test.pod) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Errorf("%s: unexpected request: %s %#v\n%#v", test.name, req.Method, req.URL, req) + return nil, fmt.Errorf("unexpected request") + } + }), + } + tf.ClientConfigVal = &restclient.Config{APIPath: "/api", ContentConfig: restclient.ContentConfig{NegotiatedSerializer: scheme.Codecs, GroupVersion: &schema.GroupVersion{Version: test.version}}} + ex := &fakeRemoteExecutor{} + if test.execErr { + ex.execErr = fmt.Errorf("exec error") + } + params := &ExecOptions{ + StreamOptions: StreamOptions{ + PodName: "foo", + ContainerName: "bar", + IOStreams: genericclioptions.NewTestIOStreamsDiscard(), + }, + Executor: ex, + } + cmd := NewCmdExec(tf, genericclioptions.NewTestIOStreamsDiscard()) + args := []string{"pod/foo", "command"} + if err := params.Complete(tf, cmd, args, -1); err != nil { + t.Fatal(err) + } + err := params.Run() + if test.execErr && err != ex.execErr { + t.Errorf("%s: Unexpected exec error: %v", test.name, err) + return + } + if !test.execErr && err != nil { + t.Errorf("%s: Unexpected error: %v", test.name, err) + return + } + if test.execErr { + return + } + if ex.url.Path != test.execPath { + t.Errorf("%s: Did not get expected path for exec request", test.name) + return + } + if strings.Count(ex.url.RawQuery, "container=bar") != 1 { + t.Errorf("%s: Did not get expected container query param for exec request", test.name) + return + } + if ex.method != "POST" { + t.Errorf("%s: Did not get method for exec request: %s", test.name, ex.method) + } + }) + } +} + +func execPod() *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "test", ResourceVersion: "10"}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + DNSPolicy: corev1.DNSClusterFirst, + Containers: []corev1.Container{ + { + Name: "bar", + }, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } +} + +func TestSetupTTY(t *testing.T) { + streams, _, _, stderr := genericclioptions.NewTestIOStreams() + + // test 1 - don't attach stdin + o := &StreamOptions{ + // InterruptParent: , + Stdin: false, + IOStreams: streams, + TTY: true, + } + + tty := o.SetupTTY() + + if o.In != nil { + t.Errorf("don't attach stdin: o.In should be nil") + } + if tty.In != nil { + t.Errorf("don't attach stdin: tty.In should be nil") + } + if o.TTY { + t.Errorf("don't attach stdin: o.TTY should be false") + } + if tty.Raw { + t.Errorf("don't attach stdin: tty.Raw should be false") + } + if len(stderr.String()) > 0 { + t.Errorf("don't attach stdin: stderr wasn't empty: %s", stderr.String()) + } + + // tests from here on attach stdin + // test 2 - don't request a TTY + o.Stdin = true + o.In = &bytes.Buffer{} + o.TTY = false + + tty = o.SetupTTY() + + if o.In == nil { + t.Errorf("attach stdin, no TTY: o.In should not be nil") + } + if tty.In != o.In { + t.Errorf("attach stdin, no TTY: tty.In should equal o.In") + } + if o.TTY { + t.Errorf("attach stdin, no TTY: o.TTY should be false") + } + if tty.Raw { + t.Errorf("attach stdin, no TTY: tty.Raw should be false") + } + if len(stderr.String()) > 0 { + t.Errorf("attach stdin, no TTY: stderr wasn't empty: %s", stderr.String()) + } + + // test 3 - request a TTY, but stdin is not a terminal + o.Stdin = true + o.In = &bytes.Buffer{} + o.ErrOut = stderr + o.TTY = true + + tty = o.SetupTTY() + + if o.In == nil { + t.Errorf("attach stdin, TTY, not a terminal: o.In should not be nil") + } + if tty.In != o.In { + t.Errorf("attach stdin, TTY, not a terminal: tty.In should equal o.In") + } + if o.TTY { + t.Errorf("attach stdin, TTY, not a terminal: o.TTY should be false") + } + if tty.Raw { + t.Errorf("attach stdin, TTY, not a terminal: tty.Raw should be false") + } + if !strings.Contains(stderr.String(), "input is not a terminal") { + t.Errorf("attach stdin, TTY, not a terminal: expected 'input is not a terminal' to stderr") + } + + // test 4 - request a TTY, stdin is a terminal + o.Stdin = true + o.In = &bytes.Buffer{} + stderr.Reset() + o.TTY = true + + overrideStdin := ioutil.NopCloser(&bytes.Buffer{}) + overrideStdout := &bytes.Buffer{} + overrideStderr := &bytes.Buffer{} + o.overrideStreams = func() (io.ReadCloser, io.Writer, io.Writer) { + return overrideStdin, overrideStdout, overrideStderr + } + + o.isTerminalIn = func(tty term.TTY) bool { + return true + } + + tty = o.SetupTTY() + + if o.In != overrideStdin { + t.Errorf("attach stdin, TTY, is a terminal: o.In should equal overrideStdin") + } + if tty.In != o.In { + t.Errorf("attach stdin, TTY, is a terminal: tty.In should equal o.In") + } + if !o.TTY { + t.Errorf("attach stdin, TTY, is a terminal: o.TTY should be true") + } + if !tty.Raw { + t.Errorf("attach stdin, TTY, is a terminal: tty.Raw should be true") + } + if len(stderr.String()) > 0 { + t.Errorf("attach stdin, TTY, is a terminal: stderr wasn't empty: %s", stderr.String()) + } + if o.Out != overrideStdout { + t.Errorf("attach stdin, TTY, is a terminal: o.Out should equal overrideStdout") + } + if tty.Out != o.Out { + t.Errorf("attach stdin, TTY, is a terminal: tty.Out should equal o.Out") + } +} diff --git a/pkg/cmd/explain/explain.go b/pkg/cmd/explain/explain.go new file mode 100644 index 000000000..97817fcd5 --- /dev/null +++ b/pkg/cmd/explain/explain.go @@ -0,0 +1,158 @@ +/* +Copyright 2014 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 explain + +import ( + "fmt" + + "github.com/spf13/cobra" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/explain" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/openapi" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + explainLong = templates.LongDesc(` + List the fields for supported resources + + This command describes the fields associated with each supported API resource. + Fields are identified via a simple JSONPath identifier: + + .[.] + + Add the --recursive flag to display all of the fields at once without descriptions. + Information about each field is retrieved from the server in OpenAPI format.`) + + explainExamples = templates.Examples(i18n.T(` + # Get the documentation of the resource and its fields + kubectl explain pods + + # Get the documentation of a specific field of a resource + kubectl explain pods.spec.containers`)) +) + +type ExplainOptions struct { + genericclioptions.IOStreams + + CmdParent string + APIVersion string + Recursive bool + + Mapper meta.RESTMapper + Schema openapi.Resources +} + +func NewExplainOptions(parent string, streams genericclioptions.IOStreams) *ExplainOptions { + return &ExplainOptions{ + IOStreams: streams, + CmdParent: parent, + } +} + +// NewCmdExplain returns a cobra command for swagger docs +func NewCmdExplain(parent string, f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewExplainOptions(parent, streams) + + cmd := &cobra.Command{ + Use: "explain RESOURCE", + DisableFlagsInUseLine: true, + Short: i18n.T("Documentation of resources"), + Long: explainLong + "\n\n" + cmdutil.SuggestAPIResources(parent), + Example: explainExamples, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(o.Validate(args)) + cmdutil.CheckErr(o.Run(args)) + }, + } + cmd.Flags().BoolVar(&o.Recursive, "recursive", o.Recursive, "Print the fields of fields (Currently only 1 level deep)") + cmd.Flags().StringVar(&o.APIVersion, "api-version", o.APIVersion, "Get different explanations for particular API version") + return cmd +} + +func (o *ExplainOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + var err error + o.Mapper, err = f.ToRESTMapper() + if err != nil { + return err + } + + o.Schema, err = f.OpenAPISchema() + if err != nil { + return err + } + return nil +} + +func (o *ExplainOptions) Validate(args []string) error { + if len(args) == 0 { + return fmt.Errorf("You must specify the type of resource to explain. %s\n", cmdutil.SuggestAPIResources(o.CmdParent)) + } + if len(args) > 1 { + return fmt.Errorf("We accept only this format: explain RESOURCE\n") + } + + return nil +} + +// Run executes the appropriate steps to print a model's documentation +func (o *ExplainOptions) Run(args []string) error { + recursive := o.Recursive + apiVersionString := o.APIVersion + + // TODO: After we figured out the new syntax to separate group and resource, allow + // the users to use it in explain (kubectl explain ). + // Refer to issue #16039 for why we do this. Refer to PR #15808 that used "/" syntax. + inModel, fieldsPath, err := explain.SplitAndParseResourceRequest(args[0], o.Mapper) + if err != nil { + return err + } + + // TODO: We should deduce the group for a resource by discovering the supported resources at server. + fullySpecifiedGVR, groupResource := schema.ParseResourceArg(inModel) + gvk := schema.GroupVersionKind{} + if fullySpecifiedGVR != nil { + gvk, _ = o.Mapper.KindFor(*fullySpecifiedGVR) + } + if gvk.Empty() { + gvk, err = o.Mapper.KindFor(groupResource.WithVersion("")) + if err != nil { + return err + } + } + + if len(apiVersionString) != 0 { + apiVersion, err := schema.ParseGroupVersion(apiVersionString) + if err != nil { + return err + } + gvk = apiVersion.WithKind(gvk.Kind) + } + + schema := o.Schema.LookupResource(gvk) + if schema == nil { + return fmt.Errorf("Couldn't find resource for %q", gvk) + } + + return explain.PrintModelDescription(fieldsPath, o.Out, schema, gvk, recursive) +} diff --git a/pkg/cmd/expose/expose.go b/pkg/cmd/expose/expose.go new file mode 100644 index 000000000..bf010ded3 --- /dev/null +++ b/pkg/cmd/expose/expose.go @@ -0,0 +1,359 @@ +/* +Copyright 2014 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 expose + +import ( + "regexp" + "strings" + + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructuredscheme" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/dynamic" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + exposeResources = `pod (po), service (svc), replicationcontroller (rc), deployment (deploy), replicaset (rs)` + + exposeLong = templates.LongDesc(` + Expose a resource as a new Kubernetes service. + + Looks up a deployment, service, replica set, replication controller or pod by name and uses the selector + for that resource as the selector for a new service on the specified port. A deployment or replica set + will be exposed as a service only if its selector is convertible to a selector that service supports, + i.e. when the selector contains only the matchLabels component. Note that if no port is specified via + --port and the exposed resource has multiple ports, all will be re-used by the new service. Also if no + labels are specified, the new service will re-use the labels from the resource it exposes. + + Possible resources include (case insensitive): + + ` + exposeResources) + + exposeExample = templates.Examples(i18n.T(` + # Create a service for a replicated nginx, which serves on port 80 and connects to the containers on port 8000. + kubectl expose rc nginx --port=80 --target-port=8000 + + # Create a service for a replication controller identified by type and name specified in "nginx-controller.yaml", which serves on port 80 and connects to the containers on port 8000. + kubectl expose -f nginx-controller.yaml --port=80 --target-port=8000 + + # Create a service for a pod valid-pod, which serves on port 444 with the name "frontend" + kubectl expose pod valid-pod --port=444 --name=frontend + + # Create a second service based on the above service, exposing the container port 8443 as port 443 with the name "nginx-https" + kubectl expose service nginx --port=443 --target-port=8443 --name=nginx-https + + # Create a service for a replicated streaming application on port 4100 balancing UDP traffic and named 'video-stream'. + kubectl expose rc streamer --port=4100 --protocol=UDP --name=video-stream + + # Create a service for a replicated nginx using replica set, which serves on port 80 and connects to the containers on port 8000. + kubectl expose rs nginx --port=80 --target-port=8000 + + # Create a service for an nginx deployment, which serves on port 80 and connects to the containers on port 8000. + kubectl expose deployment nginx --port=80 --target-port=8000`)) +) + +type ExposeServiceOptions struct { + FilenameOptions resource.FilenameOptions + RecordFlags *genericclioptions.RecordFlags + PrintFlags *genericclioptions.PrintFlags + PrintObj printers.ResourcePrinterFunc + + DryRun bool + EnforceNamespace bool + + Generators func(string) map[string]generate.Generator + CanBeExposed polymorphichelpers.CanBeExposedFunc + MapBasedSelectorForObject func(runtime.Object) (string, error) + PortsForObject polymorphichelpers.PortsForObjectFunc + ProtocolsForObject func(runtime.Object) (map[string]string, error) + + Namespace string + Mapper meta.RESTMapper + + DynamicClient dynamic.Interface + Builder *resource.Builder + + Recorder genericclioptions.Recorder + genericclioptions.IOStreams +} + +func NewExposeServiceOptions(ioStreams genericclioptions.IOStreams) *ExposeServiceOptions { + return &ExposeServiceOptions{ + RecordFlags: genericclioptions.NewRecordFlags(), + PrintFlags: genericclioptions.NewPrintFlags("exposed").WithTypeSetter(scheme.Scheme), + + Recorder: genericclioptions.NoopRecorder{}, + IOStreams: ioStreams, + } +} + +func NewCmdExposeService(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewExposeServiceOptions(streams) + + validArgs := []string{} + resources := regexp.MustCompile(`\s*,`).Split(exposeResources, -1) + for _, r := range resources { + validArgs = append(validArgs, strings.Fields(r)[0]) + } + + cmd := &cobra.Command{ + Use: "expose (-f FILENAME | TYPE NAME) [--port=port] [--protocol=TCP|UDP|SCTP] [--target-port=number-or-name] [--name=name] [--external-ip=external-ip-of-service] [--type=type]", + DisableFlagsInUseLine: true, + Short: i18n.T("Take a replication controller, service, deployment or pod and expose it as a new Kubernetes Service"), + Long: exposeLong, + Example: exposeExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(o.RunExpose(cmd, args)) + }, + ValidArgs: validArgs, + } + + o.RecordFlags.AddFlags(cmd) + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().String("generator", "service/v2", i18n.T("The name of the API generator to use. There are 2 generators: 'service/v1' and 'service/v2'. The only difference between them is that service port in v1 is named 'default', while it is left unnamed in v2. Default is 'service/v2'.")) + cmd.Flags().String("protocol", "", i18n.T("The network protocol for the service to be created. Default is 'TCP'.")) + cmd.Flags().String("port", "", i18n.T("The port that the service should serve on. Copied from the resource being exposed, if unspecified")) + cmd.Flags().String("type", "", i18n.T("Type for this service: ClusterIP, NodePort, LoadBalancer, or ExternalName. Default is 'ClusterIP'.")) + cmd.Flags().String("load-balancer-ip", "", i18n.T("IP to assign to the LoadBalancer. If empty, an ephemeral IP will be created and used (cloud-provider specific).")) + cmd.Flags().String("selector", "", i18n.T("A label selector to use for this service. Only equality-based selector requirements are supported. If empty (the default) infer the selector from the replication controller or replica set.)")) + cmd.Flags().StringP("labels", "l", "", "Labels to apply to the service created by this call.") + cmd.Flags().String("container-port", "", i18n.T("Synonym for --target-port")) + cmd.Flags().MarkDeprecated("container-port", "--container-port will be removed in the future, please use --target-port instead") + cmd.Flags().String("target-port", "", i18n.T("Name or number for the port on the container that the service should direct traffic to. Optional.")) + cmd.Flags().String("external-ip", "", i18n.T("Additional external IP address (not managed by Kubernetes) to accept for the service. If this IP is routed to a node, the service can be accessed by this IP in addition to its generated service IP.")) + cmd.Flags().String("overrides", "", i18n.T("An inline JSON override for the generated object. If this is non-empty, it is used to override the generated object. Requires that the object supply a valid apiVersion field.")) + cmd.Flags().String("name", "", i18n.T("The name for the newly created object.")) + cmd.Flags().String("session-affinity", "", i18n.T("If non-empty, set the session affinity for the service to this; legal values: 'None', 'ClientIP'")) + cmd.Flags().String("cluster-ip", "", i18n.T("ClusterIP to be assigned to the service. Leave empty to auto-allocate, or set to 'None' to create a headless service.")) + + usage := "identifying the resource to expose a service" + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + cmdutil.AddDryRunFlag(cmd) + cmdutil.AddApplyAnnotationFlags(cmd) + return cmd +} + +func (o *ExposeServiceOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + o.DryRun = cmdutil.GetDryRunFlag(cmd) + + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = printer.PrintObj + + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.DynamicClient, err = f.DynamicClient() + if err != nil { + return err + } + + o.Generators = generateversioned.GeneratorFn + o.Builder = f.NewBuilder() + o.CanBeExposed = polymorphichelpers.CanBeExposedFn + o.MapBasedSelectorForObject = polymorphichelpers.MapBasedSelectorForObjectFn + o.ProtocolsForObject = polymorphichelpers.ProtocolsForObjectFn + o.PortsForObject = polymorphichelpers.PortsForObjectFn + + o.Mapper, err = f.ToRESTMapper() + if err != nil { + return err + } + + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + return err +} + +func (o *ExposeServiceOptions) RunExpose(cmd *cobra.Command, args []string) error { + r := o.Builder. + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + ContinueOnError(). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.FilenameOptions). + ResourceTypeOrNameArgs(false, args...). + Flatten(). + Do() + err := r.Err() + if err != nil { + return cmdutil.UsageErrorf(cmd, err.Error()) + } + + // Get the generator, setup and validate all required parameters + generatorName := cmdutil.GetFlagString(cmd, "generator") + generators := o.Generators("expose") + generator, found := generators[generatorName] + if !found { + return cmdutil.UsageErrorf(cmd, "generator %q not found.", generatorName) + } + names := generator.ParamNames() + + err = r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + mapping := info.ResourceMapping() + if err := o.CanBeExposed(mapping.GroupVersionKind.GroupKind()); err != nil { + return err + } + + params := generate.MakeParams(cmd, names) + name := info.Name + if len(name) > validation.DNS1035LabelMaxLength { + name = name[:validation.DNS1035LabelMaxLength] + } + params["default-name"] = name + + // For objects that need a pod selector, derive it from the exposed object in case a user + // didn't explicitly specify one via --selector + if s, found := params["selector"]; found && generate.IsZero(s) { + s, err := o.MapBasedSelectorForObject(info.Object) + if err != nil { + return cmdutil.UsageErrorf(cmd, "couldn't retrieve selectors via --selector flag or introspection: %v", err) + } + params["selector"] = s + } + + isHeadlessService := params["cluster-ip"] == "None" + + // For objects that need a port, derive it from the exposed object in case a user + // didn't explicitly specify one via --port + if port, found := params["port"]; found && generate.IsZero(port) { + ports, err := o.PortsForObject(info.Object) + if err != nil { + return cmdutil.UsageErrorf(cmd, "couldn't find port via --port flag or introspection: %v", err) + } + switch len(ports) { + case 0: + if !isHeadlessService { + return cmdutil.UsageErrorf(cmd, "couldn't find port via --port flag or introspection") + } + case 1: + params["port"] = ports[0] + default: + params["ports"] = strings.Join(ports, ",") + } + } + + // Always try to derive protocols from the exposed object, may use + // different protocols for different ports. + if _, found := params["protocol"]; found { + protocolsMap, err := o.ProtocolsForObject(info.Object) + if err != nil { + return cmdutil.UsageErrorf(cmd, "couldn't find protocol via introspection: %v", err) + } + if protocols := generate.MakeProtocols(protocolsMap); !generate.IsZero(protocols) { + params["protocols"] = protocols + } + } + + if generate.IsZero(params["labels"]) { + labels, err := meta.NewAccessor().Labels(info.Object) + if err != nil { + return err + } + params["labels"] = polymorphichelpers.MakeLabels(labels) + } + if err = generate.ValidateParams(names, params); err != nil { + return err + } + // Check for invalid flags used against the present generator. + if err := generate.EnsureFlagsValid(cmd, generators, generatorName); err != nil { + return err + } + + // Generate new object + object, err := generator.Generate(params) + if err != nil { + return err + } + + if inline := cmdutil.GetFlagString(cmd, "overrides"); len(inline) > 0 { + codec := runtime.NewCodec(scheme.DefaultJSONEncoder(), scheme.Codecs.UniversalDecoder(scheme.Scheme.PrioritizedVersionsAllGroups()...)) + object, err = cmdutil.Merge(codec, object, inline) + if err != nil { + return err + } + } + + if err := o.Recorder.Record(object); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + if o.DryRun { + return o.PrintObj(object, o.Out) + } + if err := util.CreateOrUpdateAnnotation(cmdutil.GetFlagBool(cmd, cmdutil.ApplyAnnotationsFlag), object, scheme.DefaultJSONEncoder()); err != nil { + return err + } + + asUnstructured := &unstructured.Unstructured{} + if err := scheme.Scheme.Convert(object, asUnstructured, nil); err != nil { + return err + } + gvks, _, err := unstructuredscheme.NewUnstructuredObjectTyper().ObjectKinds(asUnstructured) + if err != nil { + return err + } + objMapping, err := o.Mapper.RESTMapping(gvks[0].GroupKind(), gvks[0].Version) + if err != nil { + return err + } + // Serialize the object with the annotation applied. + actualObject, err := o.DynamicClient.Resource(objMapping.Resource).Namespace(o.Namespace).Create(asUnstructured, metav1.CreateOptions{}) + if err != nil { + return err + } + + return o.PrintObj(actualObject, o.Out) + }) + if err != nil { + return err + } + return nil +} diff --git a/pkg/cmd/expose/expose_test.go b/pkg/cmd/expose/expose_test.go new file mode 100644 index 000000000..a0ea1a2bf --- /dev/null +++ b/pkg/cmd/expose/expose_test.go @@ -0,0 +1,639 @@ +/* +Copyright 2015 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 expose + +import ( + "fmt" + "net/http" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestRunExposeService(t *testing.T) { + tests := []struct { + name string + args []string + ns string + calls map[string]string + input runtime.Object + flags map[string]string + output runtime.Object + expected string + status int + }{ + { + name: "expose-service-from-service-no-selector-defined", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"protocol": "UDP", "port": "14", "name": "foo", "labels": "svc=test"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolUDP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"app": "go"}, + }, + }, + expected: "service/foo exposed", + status: 200, + }, + { + name: "expose-service-from-service", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"selector": "func=stream", "protocol": "UDP", "port": "14", "name": "foo", "labels": "svc=test"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolUDP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"func": "stream"}, + }, + }, + expected: "service/foo exposed", + status: 200, + }, + { + name: "no-name-passed-from-the-cli", + args: []string{"service", "mayor"}, + ns: "default", + calls: map[string]string{ + "GET": "/namespaces/default/services/mayor", + "POST": "/namespaces/default/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "mayor", Namespace: "default", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"run": "this"}, + }, + }, + // No --name flag specified below. Service will use the rc's name passed via the 'default-name' parameter + flags: map[string]string{"selector": "run=this", "port": "80", "labels": "runas=amayor"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "mayor", Namespace: "", Labels: map[string]string{"runas": "amayor"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.FromInt(80), + }, + }, + Selector: map[string]string{"run": "this"}, + }, + }, + expected: "service/mayor exposed", + status: 200, + }, + { + name: "expose-service", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"selector": "func=stream", "protocol": "UDP", "port": "14", "name": "foo", "labels": "svc=test", "type": "LoadBalancer", "dry-run": "true"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolUDP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"func": "stream"}, + Type: corev1.ServiceTypeLoadBalancer, + }, + }, + status: 200, + }, + { + name: "expose-affinity-service", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"selector": "func=stream", "protocol": "UDP", "port": "14", "name": "foo", "labels": "svc=test", "type": "LoadBalancer", "session-affinity": "ClientIP", "dry-run": "true"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolUDP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"func": "stream"}, + Type: corev1.ServiceTypeLoadBalancer, + SessionAffinity: corev1.ServiceAffinityClientIP, + }, + }, + status: 200, + }, + { + name: "expose-service-cluster-ip", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"selector": "func=stream", "protocol": "UDP", "port": "14", "name": "foo", "labels": "svc=test", "cluster-ip": "10.10.10.10", "dry-run": "true"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolUDP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"func": "stream"}, + ClusterIP: "10.10.10.10", + }, + }, + expected: "service /foo exposed", + status: 200, + }, + { + name: "expose-headless-service", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"selector": "func=stream", "protocol": "UDP", "port": "14", "name": "foo", "labels": "svc=test", "cluster-ip": "None", "dry-run": "true"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolUDP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"func": "stream"}, + ClusterIP: corev1.ClusterIPNone, + }, + }, + expected: "service/foo exposed", + status: 200, + }, + { + name: "expose-headless-service-no-port", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"selector": "func=stream", "name": "foo", "labels": "svc=test", "cluster-ip": "None", "dry-run": "true"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{}, + Selector: map[string]string{"func": "stream"}, + ClusterIP: corev1.ClusterIPNone, + }, + }, + expected: "service/foo exposed", + status: 200, + }, + { + name: "expose-from-file", + args: []string{}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/redis-master", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "redis-master", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"filename": "../../../../test/e2e/testing-manifests/guestbook/redis-master-service.yaml", "selector": "func=stream", "protocol": "UDP", "port": "14", "name": "foo", "labels": "svc=test", "dry-run": "true"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolUDP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"func": "stream"}, + }, + }, + status: 200, + }, + { + name: "truncate-name", + args: []string{"pod", "a-name-that-is-toooo-big-for-a-service-because-it-can-only-handle-63-characters"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/pods/a-name-that-is-toooo-big-for-a-service-because-it-can-only-handle-63-characters", + "POST": "/namespaces/test/services", + }, + input: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + }, + flags: map[string]string{"selector": "svc=frompod", "port": "90", "labels": "svc=frompod", "generator": "service/v2"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "a-name-that-is-toooo-big-for-a-service-because-it-can-only-handle-63-characters", Namespace: "", Labels: map[string]string{"svc": "frompod"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolTCP, + Port: 90, + TargetPort: intstr.FromInt(90), + }, + }, + Selector: map[string]string{"svc": "frompod"}, + }, + }, + expected: "service/a-name-that-is-toooo-big-for-a-service-because-it-can-only-hand exposed", + status: 200, + }, + { + name: "expose-multiport-object", + args: []string{"service", "foo"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/foo", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "multiport"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.FromInt(80), + }, + { + Protocol: corev1.ProtocolTCP, + Port: 443, + TargetPort: intstr.FromInt(443), + }, + }, + }, + }, + flags: map[string]string{"selector": "svc=fromfoo", "generator": "service/v2", "name": "fromfoo", "dry-run": "true"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "fromfoo", Namespace: "", Labels: map[string]string{"svc": "multiport"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "port-1", + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.FromInt(80), + }, + { + Name: "port-2", + Protocol: corev1.ProtocolTCP, + Port: 443, + TargetPort: intstr.FromInt(443), + }, + }, + Selector: map[string]string{"svc": "fromfoo"}, + }, + }, + status: 200, + }, + { + name: "expose-multiprotocol-object", + args: []string{"service", "foo"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/foo", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "multiport"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.FromInt(80), + }, + { + Protocol: corev1.ProtocolUDP, + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + { + Protocol: corev1.ProtocolUDP, + Port: 8081, + TargetPort: intstr.FromInt(8081), + }, + { + Protocol: corev1.ProtocolSCTP, + Port: 8082, + TargetPort: intstr.FromInt(8082), + }, + }, + }, + }, + flags: map[string]string{"selector": "svc=fromfoo", "generator": "service/v2", "name": "fromfoo", "dry-run": "true"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "fromfoo", Namespace: "", Labels: map[string]string{"svc": "multiport"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "port-1", + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.FromInt(80), + }, + { + Name: "port-2", + Protocol: corev1.ProtocolUDP, + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + { + Name: "port-3", + Protocol: corev1.ProtocolUDP, + Port: 8081, + TargetPort: intstr.FromInt(8081), + }, + { + Name: "port-4", + Protocol: corev1.ProtocolSCTP, + Port: 8082, + TargetPort: intstr.FromInt(8082), + }, + }, + Selector: map[string]string{"svc": "fromfoo"}, + }, + }, + status: 200, + }, + { + name: "expose-service-from-service-no-selector-defined-sctp", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"protocol": "SCTP", "port": "14", "name": "foo", "labels": "svc=test"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolSCTP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"app": "go"}, + }, + }, + expected: "service/foo exposed", + status: 200, + }, + { + name: "expose-service-from-service-sctp", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"selector": "func=stream", "protocol": "SCTP", "port": "14", "name": "foo", "labels": "svc=test"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolSCTP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"func": "stream"}, + }, + }, + expected: "service/foo exposed", + status: 200, + }, + { + name: "expose-service-cluster-ip-sctp", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"selector": "func=stream", "protocol": "SCTP", "port": "14", "name": "foo", "labels": "svc=test", "cluster-ip": "10.10.10.10", "dry-run": "true"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolSCTP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"func": "stream"}, + ClusterIP: "10.10.10.10", + }, + }, + expected: "service /foo exposed", + status: 200, + }, + { + name: "expose-headless-service-sctp", + args: []string{"service", "baz"}, + ns: "test", + calls: map[string]string{ + "GET": "/namespaces/test/services/baz", + "POST": "/namespaces/test/services", + }, + input: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "go"}, + }, + }, + flags: map[string]string{"selector": "func=stream", "protocol": "SCTP", "port": "14", "name": "foo", "labels": "svc=test", "cluster-ip": "None", "dry-run": "true"}, + output: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "", Labels: map[string]string{"svc": "test"}}, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolSCTP, + Port: 14, + TargetPort: intstr.FromInt(14), + }, + }, + Selector: map[string]string{"func": "stream"}, + ClusterIP: corev1.ClusterIPNone, + }, + }, + expected: "service/foo exposed", + status: 200, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace(test.ns) + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == test.calls[m] && m == "GET": + return &http.Response{StatusCode: test.status, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, test.input)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdExposeService(tf, ioStreams) + cmd.SetOutput(buf) + for flag, value := range test.flags { + cmd.Flags().Set(flag, value) + } + cmd.Run(cmd, test.args) + + out := buf.String() + if _, ok := test.flags["dry-run"]; ok { + test.expected = fmt.Sprintf("service/%s exposed (dry run)", test.flags["name"]) + } + + if !strings.Contains(out, test.expected) { + t.Errorf("%s: Unexpected output! Expected\n%s\ngot\n%s", test.name, test.expected, out) + } + }) + } +} diff --git a/pkg/cmd/help/help.go b/pkg/cmd/help/help.go new file mode 100644 index 000000000..cf8843dbc --- /dev/null +++ b/pkg/cmd/help/help.go @@ -0,0 +1,84 @@ +/* +Copyright 2016 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 help + +import ( + "strings" + + "github.com/spf13/cobra" + + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var helpLong = templates.LongDesc(i18n.T(` + Help provides help for any command in the application. + Simply type kubectl help [path to command] for full details.`)) + +// NewCmdHelp returns the help Cobra command +func NewCmdHelp() *cobra.Command { + cmd := &cobra.Command{ + Use: "help [command] | STRING_TO_SEARCH", + DisableFlagsInUseLine: true, + Short: i18n.T("Help about any command"), + Long: helpLong, + + Run: RunHelp, + } + + return cmd +} + +// RunHelp checks given arguments and executes command +func RunHelp(cmd *cobra.Command, args []string) { + foundCmd, _, err := cmd.Root().Find(args) + + // NOTE(andreykurilin): actually, I did not find any cases when foundCmd can be nil, + // but let's make this check since it is included in original code of initHelpCmd + // from github.com/spf13/cobra + if foundCmd == nil { + cmd.Printf("Unknown help topic %#q.\n", args) + cmd.Root().Usage() + } else if err != nil { + // print error message at first, since it can contain suggestions + cmd.Println(err) + + argsString := strings.Join(args, " ") + var matchedMsgIsPrinted = false + for _, foundCmd := range foundCmd.Commands() { + if strings.Contains(foundCmd.Short, argsString) { + if !matchedMsgIsPrinted { + cmd.Printf("Matchers of string '%s' in short descriptions of commands: \n", argsString) + matchedMsgIsPrinted = true + } + cmd.Printf(" %-14s %s\n", foundCmd.Name(), foundCmd.Short) + } + } + + if !matchedMsgIsPrinted { + // if nothing is found, just print usage + cmd.Root().Usage() + } + } else { + if len(args) == 0 { + // help message for help command :) + foundCmd = cmd + } + helpFunc := foundCmd.HelpFunc() + helpFunc(foundCmd, args) + } +} diff --git a/pkg/cmd/kustomize/kustomize.go b/pkg/cmd/kustomize/kustomize.go new file mode 100644 index 000000000..5d13098f5 --- /dev/null +++ b/pkg/cmd/kustomize/kustomize.go @@ -0,0 +1,92 @@ +/* +Copyright 2019 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 kustomize + +import ( + "errors" + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/kustomize" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "sigs.k8s.io/kustomize/pkg/fs" +) + +type kustomizeOptions struct { + kustomizationDir string +} + +var ( + kustomizeLong = templates.LongDesc(i18n.T(` +Print a set of API resources generated from instructions in a kustomization.yaml file. + +The argument must be the path to the directory containing +the file, or a git repository +URL with a path suffix specifying same with respect to the +repository root. + + kubectl kustomize somedir + `)) + + kustomizeExample = templates.Examples(i18n.T(` +# Use the current working directory + kubectl kustomize . + +# Use some shared configuration directory + kubectl kustomize /home/configuration/production + +# Use a URL + kubectl kustomize github.com/kubernetes-sigs/kustomize.git/examples/helloWorld?ref=v1.0.6 +`)) +) + +// NewCmdKustomize returns a kustomize command +func NewCmdKustomize(streams genericclioptions.IOStreams) *cobra.Command { + var o kustomizeOptions + + cmd := &cobra.Command{ + Use: "kustomize ", + Short: i18n.T("Build a kustomization target from a directory or a remote url."), + Long: kustomizeLong, + Example: kustomizeExample, + + RunE: func(cmd *cobra.Command, args []string) error { + err := o.Validate(args) + if err != nil { + return err + } + return kustomize.RunKustomizeBuild(streams.Out, fs.MakeRealFS(), o.kustomizationDir) + }, + } + + return cmd +} + +// Validate validates build command. +func (o *kustomizeOptions) Validate(args []string) error { + if len(args) > 1 { + return errors.New("specify one path to a kustomization directory") + } + if len(args) == 0 { + o.kustomizationDir = "./" + } else { + o.kustomizationDir = args[0] + } + + return nil +} diff --git a/pkg/cmd/kustomize/kustomize_test.go b/pkg/cmd/kustomize/kustomize_test.go new file mode 100644 index 000000000..3143b1de2 --- /dev/null +++ b/pkg/cmd/kustomize/kustomize_test.go @@ -0,0 +1,56 @@ +/* +Copyright 2019 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 kustomize + +import ( + "testing" +) + +func TestValidate(t *testing.T) { + var cases = []struct { + name string + args []string + path string + erMsg string + }{ + {"noargs", []string{}, "./", ""}, + {"file", []string{"beans"}, "beans", ""}, + {"path", []string{"a/b/c"}, "a/b/c", ""}, + {"path", []string{"too", "many"}, + "", "specify one path to a kustomization directory"}, + } + for _, mycase := range cases { + opts := kustomizeOptions{} + e := opts.Validate(mycase.args) + if len(mycase.erMsg) > 0 { + if e == nil { + t.Errorf("%s: Expected an error %v", mycase.name, mycase.erMsg) + } + if e.Error() != mycase.erMsg { + t.Errorf("%s: Expected error %s, but got %v", mycase.name, mycase.erMsg, e) + } + continue + } + if e != nil { + t.Errorf("%s: unknown error %v", mycase.name, e) + continue + } + if opts.kustomizationDir != mycase.path { + t.Errorf("%s: expected path '%s', got '%s'", mycase.name, mycase.path, opts.kustomizationDir) + } + } +} diff --git a/pkg/cmd/label/label.go b/pkg/cmd/label/label.go new file mode 100644 index 000000000..48a379bf0 --- /dev/null +++ b/pkg/cmd/label/label.go @@ -0,0 +1,421 @@ +/* +Copyright 2014 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 label + +import ( + "fmt" + "reflect" + "strings" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructuredscheme" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/validation" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// LabelOptions have the data required to perform the label operation +type LabelOptions struct { + // Filename options + resource.FilenameOptions + RecordFlags *genericclioptions.RecordFlags + + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + // Common user flags + overwrite bool + list bool + local bool + dryrun bool + all bool + resourceVersion string + selector string + fieldSelector string + outputFormat string + + // results of arg parsing + resources []string + newLabels map[string]string + removeLabels []string + + Recorder genericclioptions.Recorder + + namespace string + enforceNamespace bool + builder *resource.Builder + unstructuredClientForMapping func(mapping *meta.RESTMapping) (resource.RESTClient, error) + + // Common shared fields + genericclioptions.IOStreams +} + +var ( + labelLong = templates.LongDesc(i18n.T(` + Update the labels on a resource. + + * A label key and value must begin with a letter or number, and may contain letters, numbers, hyphens, dots, and underscores, up to %[1]d characters each. + * Optionally, the key can begin with a DNS subdomain prefix and a single '/', like example.com/my-app + * If --overwrite is true, then existing labels can be overwritten, otherwise attempting to overwrite a label will result in an error. + * If --resource-version is specified, then updates will use this resource version, otherwise the existing resource-version will be used.`)) + + labelExample = templates.Examples(i18n.T(` + # Update pod 'foo' with the label 'unhealthy' and the value 'true'. + kubectl label pods foo unhealthy=true + + # Update pod 'foo' with the label 'status' and the value 'unhealthy', overwriting any existing value. + kubectl label --overwrite pods foo status=unhealthy + + # Update all pods in the namespace + kubectl label pods --all status=unhealthy + + # Update a pod identified by the type and name in "pod.json" + kubectl label -f pod.json status=unhealthy + + # Update pod 'foo' only if the resource is unchanged from version 1. + kubectl label pods foo status=unhealthy --resource-version=1 + + # Update pod 'foo' by removing a label named 'bar' if it exists. + # Does not require the --overwrite flag. + kubectl label pods foo bar-`)) +) + +func NewLabelOptions(ioStreams genericclioptions.IOStreams) *LabelOptions { + return &LabelOptions{ + RecordFlags: genericclioptions.NewRecordFlags(), + Recorder: genericclioptions.NoopRecorder{}, + + PrintFlags: genericclioptions.NewPrintFlags("labeled").WithTypeSetter(scheme.Scheme), + + IOStreams: ioStreams, + } +} + +func NewCmdLabel(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewLabelOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "label [--overwrite] (-f FILENAME | TYPE NAME) KEY_1=VAL_1 ... KEY_N=VAL_N [--resource-version=version]", + DisableFlagsInUseLine: true, + Short: i18n.T("Update the labels on a resource"), + Long: fmt.Sprintf(labelLong, validation.LabelValueMaxLength), + Example: labelExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunLabel()) + }, + } + + o.RecordFlags.AddFlags(cmd) + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().BoolVar(&o.overwrite, "overwrite", o.overwrite, "If true, allow labels to be overwritten, otherwise reject label updates that overwrite existing labels.") + cmd.Flags().BoolVar(&o.list, "list", o.list, "If true, display the labels for a given resource.") + cmd.Flags().BoolVar(&o.local, "local", o.local, "If true, label will NOT contact api-server but run locally.") + cmd.Flags().StringVarP(&o.selector, "selector", "l", o.selector, "Selector (label query) to filter on, not including uninitialized ones, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2).") + cmd.Flags().StringVar(&o.fieldSelector, "field-selector", o.fieldSelector, "Selector (field query) to filter on, supports '=', '==', and '!='.(e.g. --field-selector key1=value1,key2=value2). The server only supports a limited number of field queries per type.") + cmd.Flags().BoolVar(&o.all, "all", o.all, "Select all resources, including uninitialized ones, in the namespace of the specified resource types") + cmd.Flags().StringVar(&o.resourceVersion, "resource-version", o.resourceVersion, i18n.T("If non-empty, the labels update will only succeed if this is the current resource-version for the object. Only valid when specifying a single resource.")) + usage := "identifying the resource to update the labels" + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + cmdutil.AddDryRunFlag(cmd) + cmdutil.AddIncludeUninitializedFlag(cmd) + + return cmd +} + +// Complete adapts from the command line args and factory to the data required. +func (o *LabelOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.outputFormat = cmdutil.GetFlagString(cmd, "output") + o.dryrun = cmdutil.GetDryRunFlag(cmd) + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + if o.dryrun { + o.PrintFlags.Complete("%s (dry run)") + } + + return o.PrintFlags.ToPrinter() + } + + resources, labelArgs, err := cmdutil.GetResourcesAndPairs(args, "label") + if err != nil { + return err + } + o.resources = resources + o.newLabels, o.removeLabels, err = parseLabels(labelArgs) + + if o.list && len(o.outputFormat) > 0 { + return fmt.Errorf("--list and --output may not be specified together") + } + + o.namespace, o.enforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + o.builder = f.NewBuilder() + o.unstructuredClientForMapping = f.UnstructuredClientForMapping + + return nil +} + +// Validate checks to the LabelOptions to see if there is sufficient information run the command. +func (o *LabelOptions) Validate() error { + if o.all && len(o.selector) > 0 { + return fmt.Errorf("cannot set --all and --selector at the same time") + } + if o.all && len(o.fieldSelector) > 0 { + return fmt.Errorf("cannot set --all and --field-selector at the same time") + } + if len(o.resources) < 1 && cmdutil.IsFilenameSliceEmpty(o.FilenameOptions.Filenames, o.FilenameOptions.Kustomize) { + return fmt.Errorf("one or more resources must be specified as or /") + } + if len(o.newLabels) < 1 && len(o.removeLabels) < 1 && !o.list { + return fmt.Errorf("at least one label update is required") + } + return nil +} + +// RunLabel does the work +func (o *LabelOptions) RunLabel() error { + b := o.builder. + Unstructured(). + LocalParam(o.local). + ContinueOnError(). + NamespaceParam(o.namespace).DefaultNamespace(). + FilenameParam(o.enforceNamespace, &o.FilenameOptions). + Flatten() + + if !o.local { + b = b.LabelSelectorParam(o.selector). + FieldSelectorParam(o.fieldSelector). + ResourceTypeOrNameArgs(o.all, o.resources...). + Latest() + } + + one := false + r := b.Do().IntoSingleItemImplied(&one) + if err := r.Err(); err != nil { + return err + } + + // only apply resource version locking on a single resource + if !one && len(o.resourceVersion) > 0 { + return fmt.Errorf("--resource-version may only be used with a single resource") + } + + // TODO: support bulk generic output a la Get + return r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + var outputObj runtime.Object + var dataChangeMsg string + obj := info.Object + oldData, err := json.Marshal(obj) + if err != nil { + return err + } + if o.dryrun || o.local || o.list { + err = labelFunc(obj, o.overwrite, o.resourceVersion, o.newLabels, o.removeLabels) + if err != nil { + return err + } + newObj, err := json.Marshal(obj) + if err != nil { + return err + } + dataChangeMsg = updateDataChangeMsg(oldData, newObj) + outputObj = info.Object + } else { + name, namespace := info.Name, info.Namespace + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + for _, label := range o.removeLabels { + if _, ok := accessor.GetLabels()[label]; !ok { + fmt.Fprintf(o.Out, "label %q not found.\n", label) + } + } + + if err := labelFunc(obj, o.overwrite, o.resourceVersion, o.newLabels, o.removeLabels); err != nil { + return err + } + if err := o.Recorder.Record(obj); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + newObj, err := json.Marshal(obj) + if err != nil { + return err + } + dataChangeMsg = updateDataChangeMsg(oldData, newObj) + patchBytes, err := jsonpatch.CreateMergePatch(oldData, newObj) + createdPatch := err == nil + if err != nil { + klog.V(2).Infof("couldn't compute patch: %v", err) + } + + mapping := info.ResourceMapping() + client, err := o.unstructuredClientForMapping(mapping) + if err != nil { + return err + } + helper := resource.NewHelper(client, mapping) + + if createdPatch { + outputObj, err = helper.Patch(namespace, name, types.MergePatchType, patchBytes, nil) + } else { + outputObj, err = helper.Replace(namespace, name, false, obj) + } + if err != nil { + return err + } + } + + if o.list { + accessor, err := meta.Accessor(outputObj) + if err != nil { + return err + } + + indent := "" + if !one { + indent = " " + gvks, _, err := unstructuredscheme.NewUnstructuredObjectTyper().ObjectKinds(info.Object) + if err != nil { + return err + } + fmt.Fprintf(o.ErrOut, "Listing labels for %s.%s/%s:\n", gvks[0].Kind, gvks[0].Group, info.Name) + } + for k, v := range accessor.GetLabels() { + fmt.Fprintf(o.Out, "%s%s=%s\n", indent, k, v) + } + + return nil + } + + printer, err := o.ToPrinter(dataChangeMsg) + if err != nil { + return err + } + return printer.PrintObj(info.Object, o.Out) + }) +} + +func updateDataChangeMsg(oldObj []byte, newObj []byte) string { + msg := "not labeled" + if !reflect.DeepEqual(oldObj, newObj) { + msg = "labeled" + } + return msg +} + +func validateNoOverwrites(accessor metav1.Object, labels map[string]string) error { + allErrs := []error{} + for key := range labels { + if value, found := accessor.GetLabels()[key]; found { + allErrs = append(allErrs, fmt.Errorf("'%s' already has a value (%s), and --overwrite is false", key, value)) + } + } + return utilerrors.NewAggregate(allErrs) +} + +func parseLabels(spec []string) (map[string]string, []string, error) { + labels := map[string]string{} + var remove []string + for _, labelSpec := range spec { + if strings.Contains(labelSpec, "=") { + parts := strings.Split(labelSpec, "=") + if len(parts) != 2 { + return nil, nil, fmt.Errorf("invalid label spec: %v", labelSpec) + } + if errs := validation.IsValidLabelValue(parts[1]); len(errs) != 0 { + return nil, nil, fmt.Errorf("invalid label value: %q: %s", labelSpec, strings.Join(errs, ";")) + } + labels[parts[0]] = parts[1] + } else if strings.HasSuffix(labelSpec, "-") { + remove = append(remove, labelSpec[:len(labelSpec)-1]) + } else { + return nil, nil, fmt.Errorf("unknown label spec: %v", labelSpec) + } + } + for _, removeLabel := range remove { + if _, found := labels[removeLabel]; found { + return nil, nil, fmt.Errorf("can not both modify and remove a label in the same command") + } + } + return labels, remove, nil +} + +func labelFunc(obj runtime.Object, overwrite bool, resourceVersion string, labels map[string]string, remove []string) error { + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + if !overwrite { + if err := validateNoOverwrites(accessor, labels); err != nil { + return err + } + } + + objLabels := accessor.GetLabels() + if objLabels == nil { + objLabels = make(map[string]string) + } + + for key, value := range labels { + objLabels[key] = value + } + for _, label := range remove { + delete(objLabels, label) + } + accessor.SetLabels(objLabels) + + if len(resourceVersion) != 0 { + accessor.SetResourceVersion(resourceVersion) + } + return nil +} diff --git a/pkg/cmd/label/label_test.go b/pkg/cmd/label/label_test.go new file mode 100644 index 000000000..93d5c448f --- /dev/null +++ b/pkg/cmd/label/label_test.go @@ -0,0 +1,498 @@ +/* +Copyright 2014 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 label + +import ( + "bytes" + "net/http" + "reflect" + "strings" + "testing" + + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestValidateLabels(t *testing.T) { + tests := []struct { + meta *metav1.ObjectMeta + labels map[string]string + expectErr bool + test string + }{ + { + meta: &metav1.ObjectMeta{ + Labels: map[string]string{ + "a": "b", + "c": "d", + }, + }, + labels: map[string]string{ + "a": "c", + "d": "b", + }, + test: "one shared", + expectErr: true, + }, + { + meta: &metav1.ObjectMeta{ + Labels: map[string]string{ + "a": "b", + "c": "d", + }, + }, + labels: map[string]string{ + "b": "d", + "c": "a", + }, + test: "second shared", + expectErr: true, + }, + { + meta: &metav1.ObjectMeta{ + Labels: map[string]string{ + "a": "b", + "c": "d", + }, + }, + labels: map[string]string{ + "b": "a", + "d": "c", + }, + test: "no overlap", + }, + { + meta: &metav1.ObjectMeta{}, + labels: map[string]string{ + "b": "a", + "d": "c", + }, + test: "no labels", + }, + } + for _, test := range tests { + err := validateNoOverwrites(test.meta, test.labels) + if test.expectErr && err == nil { + t.Errorf("%s: unexpected non-error", test.test) + } + if !test.expectErr && err != nil { + t.Errorf("%s: unexpected error: %v", test.test, err) + } + } +} + +func TestParseLabels(t *testing.T) { + tests := []struct { + labels []string + expected map[string]string + expectedRemove []string + expectErr bool + }{ + { + labels: []string{"a=b", "c=d"}, + expected: map[string]string{"a": "b", "c": "d"}, + }, + { + labels: []string{}, + expected: map[string]string{}, + }, + { + labels: []string{"a=b", "c=d", "e-"}, + expected: map[string]string{"a": "b", "c": "d"}, + expectedRemove: []string{"e"}, + }, + { + labels: []string{"ab", "c=d"}, + expectErr: true, + }, + { + labels: []string{"a=b", "c=d", "a-"}, + expectErr: true, + }, + { + labels: []string{"a="}, + expected: map[string]string{"a": ""}, + }, + { + labels: []string{"a=%^$"}, + expectErr: true, + }, + } + for _, test := range tests { + labels, remove, err := parseLabels(test.labels) + if test.expectErr && err == nil { + t.Errorf("unexpected non-error: %v", test) + } + if !test.expectErr && err != nil { + t.Errorf("unexpected error: %v %v", err, test) + } + if !reflect.DeepEqual(labels, test.expected) { + t.Errorf("expected: %v, got %v", test.expected, labels) + } + if !reflect.DeepEqual(remove, test.expectedRemove) { + t.Errorf("expected: %v, got %v", test.expectedRemove, remove) + } + } +} + +func TestLabelFunc(t *testing.T) { + tests := []struct { + obj runtime.Object + overwrite bool + version string + labels map[string]string + remove []string + expected runtime.Object + expectErr bool + }{ + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"a": "b"}, + }, + }, + labels: map[string]string{"a": "b"}, + expectErr: true, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"a": "b"}, + }, + }, + labels: map[string]string{"a": "c"}, + overwrite: true, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"a": "c"}, + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"a": "b"}, + }, + }, + labels: map[string]string{"c": "d"}, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"a": "b", "c": "d"}, + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"a": "b"}, + }, + }, + labels: map[string]string{"c": "d"}, + version: "2", + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"a": "b", "c": "d"}, + ResourceVersion: "2", + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"a": "b"}, + }, + }, + labels: map[string]string{}, + remove: []string{"a"}, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{}, + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"a": "b", "c": "d"}, + }, + }, + labels: map[string]string{"e": "f"}, + remove: []string{"a"}, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "c": "d", + "e": "f", + }, + }, + }, + }, + { + obj: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{}, + }, + labels: map[string]string{"a": "b"}, + expected: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"a": "b"}, + }, + }, + }, + } + for _, test := range tests { + err := labelFunc(test.obj, test.overwrite, test.version, test.labels, test.remove) + if test.expectErr { + if err == nil { + t.Errorf("unexpected non-error: %v", test) + } + continue + } + if !test.expectErr && err != nil { + t.Errorf("unexpected error: %v %v", err, test) + } + if !reflect.DeepEqual(test.obj, test.expected) { + t.Errorf("expected: %v, got %v", test.expected, test.obj) + } + } +} + +func TestLabelErrors(t *testing.T) { + testCases := map[string]struct { + args []string + flags map[string]string + errFn func(error) bool + }{ + "no args": { + args: []string{}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "one or more resources must be specified") }, + }, + "not enough labels": { + args: []string{"pods"}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "at least one label update is required") }, + }, + "wrong labels": { + args: []string{"pods", "-"}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "at least one label update is required") }, + }, + "wrong labels 2": { + args: []string{"pods", "=bar"}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "at least one label update is required") }, + }, + "no resources": { + args: []string{"pods-"}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "one or more resources must be specified") }, + }, + "no resources 2": { + args: []string{"pods=bar"}, + errFn: func(err error) bool { return strings.Contains(err.Error(), "one or more resources must be specified") }, + }, + "resources but no selectors": { + args: []string{"pods", "app=bar"}, + errFn: func(err error) bool { + return strings.Contains(err.Error(), "resource(s) were provided, but no name, label selector, or --all flag specified") + }, + }, + "multiple resources but no selectors": { + args: []string{"pods,deployments", "app=bar"}, + errFn: func(err error) bool { + return strings.Contains(err.Error(), "resource(s) were provided, but no name, label selector, or --all flag specified") + }, + }, + } + + for k, testCase := range testCases { + t.Run(k, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + buf := bytes.NewBuffer([]byte{}) + cmd := NewCmdLabel(tf, ioStreams) + cmd.SetOutput(buf) + + for k, v := range testCase.flags { + cmd.Flags().Set(k, v) + } + opts := NewLabelOptions(ioStreams) + err := opts.Complete(tf, cmd, testCase.args) + if err == nil { + err = opts.Validate() + } + if err == nil { + err = opts.RunLabel() + } + if !testCase.errFn(err) { + t.Errorf("%s: unexpected error: %v", k, err) + return + } + if buf.Len() > 0 { + t.Errorf("buffer should be empty: %s", string(buf.Bytes())) + } + }) + } +} + +func TestLabelForResourceFromFile(t *testing.T) { + pods, _, _ := cmdtesting.TestData() + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch req.Method { + case "GET": + switch req.URL.Path { + case "/namespaces/test/replicationcontrollers/cassandra": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + case "PATCH": + switch req.URL.Path { + case "/namespaces/test/replicationcontrollers/cassandra": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + default: + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdLabel(tf, ioStreams) + opts := NewLabelOptions(ioStreams) + opts.Filenames = []string{"../../../../test/e2e/testing-manifests/statefulset/cassandra/controller.yaml"} + err := opts.Complete(tf, cmd, []string{"a=b"}) + if err == nil { + err = opts.Validate() + } + if err == nil { + err = opts.RunLabel() + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "labeled") { + t.Errorf("did not set labels: %s", buf.String()) + } +} + +func TestLabelLocal(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdLabel(tf, ioStreams) + opts := NewLabelOptions(ioStreams) + opts.Filenames = []string{"../../../../test/e2e/testing-manifests/statefulset/cassandra/controller.yaml"} + opts.local = true + err := opts.Complete(tf, cmd, []string{"a=b"}) + if err == nil { + err = opts.Validate() + } + if err == nil { + err = opts.RunLabel() + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "labeled") { + t.Errorf("did not set labels: %s", buf.String()) + } +} + +func TestLabelMultipleObjects(t *testing.T) { + pods, _, _ := cmdtesting.TestData() + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch req.Method { + case "GET": + switch req.URL.Path { + case "/namespaces/test/pods": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, pods)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + case "PATCH": + switch req.URL.Path { + case "/namespaces/test/pods/foo": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[0])}, nil + case "/namespaces/test/pods/bar": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &pods.Items[1])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + default: + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, buf, _ := genericclioptions.NewTestIOStreams() + opts := NewLabelOptions(ioStreams) + opts.all = true + cmd := NewCmdLabel(tf, ioStreams) + err := opts.Complete(tf, cmd, []string{"pods", "a=b"}) + if err == nil { + err = opts.Validate() + } + if err == nil { + err = opts.RunLabel() + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Count(buf.String(), "labeled") != len(pods.Items) { + t.Errorf("not all labels are set: %s", buf.String()) + } +} diff --git a/pkg/cmd/logs/logs.go b/pkg/cmd/logs/logs.go new file mode 100644 index 000000000..3c0e5137e --- /dev/null +++ b/pkg/cmd/logs/logs.go @@ -0,0 +1,393 @@ +/* +Copyright 2014 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 logs + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "sync" + "time" + + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +const ( + logsUsageStr = "logs [-f] [-p] (POD | TYPE/NAME) [-c CONTAINER]" +) + +var ( + logsExample = templates.Examples(i18n.T(` + # Return snapshot logs from pod nginx with only one container + kubectl logs nginx + + # Return snapshot logs from pod nginx with multi containers + kubectl logs nginx --all-containers=true + + # Return snapshot logs from all containers in pods defined by label app=nginx + kubectl logs -lapp=nginx --all-containers=true + + # Return snapshot of previous terminated ruby container logs from pod web-1 + kubectl logs -p -c ruby web-1 + + # Begin streaming the logs of the ruby container in pod web-1 + kubectl logs -f -c ruby web-1 + + # Begin streaming the logs from all containers in pods defined by label app=nginx + kubectl logs -f -lapp=nginx --all-containers=true + + # Display only the most recent 20 lines of output in pod nginx + kubectl logs --tail=20 nginx + + # Show all logs from pod nginx written in the last hour + kubectl logs --since=1h nginx + + # Return snapshot logs from first container of a job named hello + kubectl logs job/hello + + # Return snapshot logs from container nginx-1 of a deployment named nginx + kubectl logs deployment/nginx -c nginx-1`)) + + selectorTail int64 = 10 + logsUsageErrStr = fmt.Sprintf("expected '%s'.\nPOD or TYPE/NAME is a required argument for the logs command", logsUsageStr) +) + +const ( + defaultPodLogsTimeout = 20 * time.Second +) + +type LogsOptions struct { + Namespace string + ResourceArg string + AllContainers bool + Options runtime.Object + Resources []string + + ConsumeRequestFn func(rest.ResponseWrapper, io.Writer) error + + // PodLogOptions + SinceTime string + SinceSeconds time.Duration + Follow bool + Previous bool + Timestamps bool + IgnoreLogErrors bool + LimitBytes int64 + Tail int64 + Container string + + // whether or not a container name was given via --container + ContainerNameSpecified bool + Selector string + MaxFollowConcurrency int + + Object runtime.Object + GetPodTimeout time.Duration + RESTClientGetter genericclioptions.RESTClientGetter + LogsForObject polymorphichelpers.LogsForObjectFunc + + genericclioptions.IOStreams + + TailSpecified bool +} + +func NewLogsOptions(streams genericclioptions.IOStreams, allContainers bool) *LogsOptions { + return &LogsOptions{ + IOStreams: streams, + AllContainers: allContainers, + Tail: -1, + MaxFollowConcurrency: 5, + } +} + +// NewCmdLogs creates a new pod logs command +func NewCmdLogs(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewLogsOptions(streams, false) + + cmd := &cobra.Command{ + Use: logsUsageStr, + DisableFlagsInUseLine: true, + Short: i18n.T("Print the logs for a container in a pod"), + Long: "Print the logs for a container in a pod or specified resource. If the pod has only one container, the container name is optional.", + Example: logsExample, + PreRun: func(cmd *cobra.Command, args []string) { + if len(os.Args) > 1 && os.Args[1] == "log" { + fmt.Fprintf(o.ErrOut, "%s is DEPRECATED and will be removed in a future version. Use %s instead.\n", "log", "logs") + } + }, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunLogs()) + }, + Aliases: []string{"log"}, + } + cmd.Flags().BoolVar(&o.AllContainers, "all-containers", o.AllContainers, "Get all containers' logs in the pod(s).") + cmd.Flags().BoolVarP(&o.Follow, "follow", "f", o.Follow, "Specify if the logs should be streamed.") + cmd.Flags().BoolVar(&o.Timestamps, "timestamps", o.Timestamps, "Include timestamps on each line in the log output") + cmd.Flags().Int64Var(&o.LimitBytes, "limit-bytes", o.LimitBytes, "Maximum bytes of logs to return. Defaults to no limit.") + cmd.Flags().BoolVarP(&o.Previous, "previous", "p", o.Previous, "If true, print the logs for the previous instance of the container in a pod if it exists.") + cmd.Flags().Int64Var(&o.Tail, "tail", o.Tail, "Lines of recent log file to display. Defaults to -1 with no selector, showing all log lines otherwise 10, if a selector is provided.") + cmd.Flags().BoolVar(&o.IgnoreLogErrors, "ignore-errors", o.IgnoreLogErrors, "If watching / following pod logs, allow for any errors that occur to be non-fatal") + cmd.Flags().StringVar(&o.SinceTime, "since-time", o.SinceTime, i18n.T("Only return logs after a specific date (RFC3339). Defaults to all logs. Only one of since-time / since may be used.")) + cmd.Flags().DurationVar(&o.SinceSeconds, "since", o.SinceSeconds, "Only return logs newer than a relative duration like 5s, 2m, or 3h. Defaults to all logs. Only one of since-time / since may be used.") + cmd.Flags().StringVarP(&o.Container, "container", "c", o.Container, "Print the logs of this container") + cmdutil.AddPodRunningTimeoutFlag(cmd, defaultPodLogsTimeout) + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on.") + cmd.Flags().IntVar(&o.MaxFollowConcurrency, "max-log-requests", o.MaxFollowConcurrency, "Specify maximum number of concurrent logs to follow when using by a selector. Defaults to 5.") + return cmd +} + +func (o *LogsOptions) ToLogOptions() (*corev1.PodLogOptions, error) { + logOptions := &corev1.PodLogOptions{ + Container: o.Container, + Follow: o.Follow, + Previous: o.Previous, + Timestamps: o.Timestamps, + } + + if len(o.SinceTime) > 0 { + t, err := util.ParseRFC3339(o.SinceTime, metav1.Now) + if err != nil { + return nil, err + } + + logOptions.SinceTime = &t + } + + if o.LimitBytes != 0 { + logOptions.LimitBytes = &o.LimitBytes + } + + if o.SinceSeconds != 0 { + // round up to the nearest second + sec := int64(o.SinceSeconds.Round(time.Second).Seconds()) + logOptions.SinceSeconds = &sec + } + + if len(o.Selector) > 0 && o.Tail == -1 && !o.TailSpecified { + logOptions.TailLines = &selectorTail + } else if o.Tail != -1 { + logOptions.TailLines = &o.Tail + } + + return logOptions, nil +} + +func (o *LogsOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + o.ContainerNameSpecified = cmd.Flag("container").Changed + o.TailSpecified = cmd.Flag("tail").Changed + o.Resources = args + + switch len(args) { + case 0: + if len(o.Selector) == 0 { + return cmdutil.UsageErrorf(cmd, "%s", logsUsageErrStr) + } + case 1: + o.ResourceArg = args[0] + if len(o.Selector) != 0 { + return cmdutil.UsageErrorf(cmd, "only a selector (-l) or a POD name is allowed") + } + case 2: + o.ResourceArg = args[0] + o.Container = args[1] + default: + return cmdutil.UsageErrorf(cmd, "%s", logsUsageErrStr) + } + var err error + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.ConsumeRequestFn = DefaultConsumeRequest + + o.GetPodTimeout, err = cmdutil.GetPodRunningTimeoutFlag(cmd) + if err != nil { + return err + } + + o.Options, err = o.ToLogOptions() + if err != nil { + return err + } + + o.RESTClientGetter = f + o.LogsForObject = polymorphichelpers.LogsForObjectFn + + if o.Object == nil { + builder := f.NewBuilder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace(). + SingleResourceType() + if o.ResourceArg != "" { + builder.ResourceNames("pods", o.ResourceArg) + } + if o.Selector != "" { + builder.ResourceTypes("pods").LabelSelectorParam(o.Selector) + } + infos, err := builder.Do().Infos() + if err != nil { + return err + } + if o.Selector == "" && len(infos) != 1 { + return errors.New("expected a resource") + } + o.Object = infos[0].Object + } + + return nil +} + +func (o LogsOptions) Validate() error { + if len(o.SinceTime) > 0 && o.SinceSeconds != 0 { + return fmt.Errorf("at most one of `sinceTime` or `sinceSeconds` may be specified") + } + + logsOptions, ok := o.Options.(*corev1.PodLogOptions) + if !ok { + return errors.New("unexpected logs options object") + } + if o.AllContainers && len(logsOptions.Container) > 0 { + return fmt.Errorf("--all-containers=true should not be specified with container name %s", logsOptions.Container) + } + + if o.ContainerNameSpecified && len(o.Resources) == 2 { + return fmt.Errorf("only one of -c or an inline [CONTAINER] arg is allowed") + } + + if o.LimitBytes < 0 { + return fmt.Errorf("--limit-bytes must be greater than 0") + } + + if logsOptions.SinceSeconds != nil && *logsOptions.SinceSeconds < int64(0) { + return fmt.Errorf("--since must be greater than 0") + } + + if logsOptions.TailLines != nil && *logsOptions.TailLines < -1 { + return fmt.Errorf("--tail must be greater than or equal to -1") + } + + return nil +} + +// RunLogs retrieves a pod log +func (o LogsOptions) RunLogs() error { + requests, err := o.LogsForObject(o.RESTClientGetter, o.Object, o.Options, o.GetPodTimeout, o.AllContainers) + if err != nil { + return err + } + + if o.Follow && len(requests) > 1 { + if len(requests) > o.MaxFollowConcurrency { + return fmt.Errorf( + "you are attempting to follow %d log streams, but maximum allowed concurrency is %d, use --max-log-requests to increase the limit", + len(requests), o.MaxFollowConcurrency, + ) + } + + return o.parallelConsumeRequest(requests) + } + + return o.sequentialConsumeRequest(requests) +} + +func (o LogsOptions) parallelConsumeRequest(requests []rest.ResponseWrapper) error { + reader, writer := io.Pipe() + wg := &sync.WaitGroup{} + wg.Add(len(requests)) + for _, request := range requests { + go func(request rest.ResponseWrapper) { + defer wg.Done() + if err := o.ConsumeRequestFn(request, writer); err != nil { + if !o.IgnoreLogErrors { + writer.CloseWithError(err) + + // It's important to return here to propagate the error via the pipe + return + } + + fmt.Fprintf(writer, "error: %v\n", err) + } + + }(request) + } + + go func() { + wg.Wait() + writer.Close() + }() + + _, err := io.Copy(o.Out, reader) + return err +} + +func (o LogsOptions) sequentialConsumeRequest(requests []rest.ResponseWrapper) error { + for _, request := range requests { + if err := o.ConsumeRequestFn(request, o.Out); err != nil { + return err + } + } + + return nil +} + +// DefaultConsumeRequest reads the data from request and writes into +// the out writer. It buffers data from requests until the newline or io.EOF +// occurs in the data, so it doesn't interleave logs sub-line +// when running concurrently. +// +// A successful read returns err == nil, not err == io.EOF. +// Because the function is defined to read from request until io.EOF, it does +// not treat an io.EOF as an error to be reported. +func DefaultConsumeRequest(request rest.ResponseWrapper, out io.Writer) error { + readCloser, err := request.Stream() + if err != nil { + return err + } + defer readCloser.Close() + + r := bufio.NewReader(readCloser) + for { + bytes, err := r.ReadBytes('\n') + if _, err := out.Write(bytes); err != nil { + return err + } + + if err != nil { + if err != io.EOF { + return err + } + return nil + } + } +} diff --git a/pkg/cmd/logs/logs_test.go b/pkg/cmd/logs/logs_test.go new file mode 100644 index 000000000..2fd150bbc --- /dev/null +++ b/pkg/cmd/logs/logs_test.go @@ -0,0 +1,545 @@ +/* +Copyright 2014 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 logs + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "strings" + "sync" + "testing" + "testing/iotest" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestLog(t *testing.T) { + tests := []struct { + name string + opts func(genericclioptions.IOStreams) *LogsOptions + expectedErr string + expectedOutSubstrings []string + }{ + { + name: "v1 - pod log", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + mock := &logTestMock{ + logsForObjectRequests: []restclient.ResponseWrapper{ + &responseWrapperMock{data: strings.NewReader("test log content\n")}, + }, + } + + o := NewLogsOptions(streams, false) + o.LogsForObject = mock.mockLogsForObject + o.ConsumeRequestFn = mock.mockConsumeRequest + + return o + }, + expectedOutSubstrings: []string{"test log content\n"}, + }, + { + name: "get logs from multiple requests sequentially", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + mock := &logTestMock{ + logsForObjectRequests: []restclient.ResponseWrapper{ + &responseWrapperMock{data: strings.NewReader("test log content from source 1\n")}, + &responseWrapperMock{data: strings.NewReader("test log content from source 2\n")}, + &responseWrapperMock{data: strings.NewReader("test log content from source 3\n")}, + }, + } + + o := NewLogsOptions(streams, false) + o.LogsForObject = mock.mockLogsForObject + o.ConsumeRequestFn = mock.mockConsumeRequest + return o + }, + expectedOutSubstrings: []string{ + // Order in this case must always be the same, because we read requests sequentially + "test log content from source 1\ntest log content from source 2\ntest log content from source 3\n", + }, + }, + { + name: "follow logs from multiple requests concurrently", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + wg := &sync.WaitGroup{} + mock := &logTestMock{ + logsForObjectRequests: []restclient.ResponseWrapper{ + &responseWrapperMock{data: strings.NewReader("test log content from source 1\n")}, + &responseWrapperMock{data: strings.NewReader("test log content from source 2\n")}, + &responseWrapperMock{data: strings.NewReader("test log content from source 3\n")}, + }, + wg: wg, + } + wg.Add(3) + + o := NewLogsOptions(streams, false) + o.LogsForObject = mock.mockLogsForObject + o.ConsumeRequestFn = mock.mockConsumeRequest + o.Follow = true + return o + }, + expectedOutSubstrings: []string{ + "test log content from source 1\n", + "test log content from source 2\n", + "test log content from source 3\n", + }, + }, + { + name: "fail to follow logs from multiple requests when there are more logs sources then MaxFollowConcurrency allows", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + wg := &sync.WaitGroup{} + mock := &logTestMock{ + logsForObjectRequests: []restclient.ResponseWrapper{ + &responseWrapperMock{data: strings.NewReader("test log content\n")}, + &responseWrapperMock{data: strings.NewReader("test log content\n")}, + &responseWrapperMock{data: strings.NewReader("test log content\n")}, + }, + wg: wg, + } + wg.Add(3) + + o := NewLogsOptions(streams, false) + o.LogsForObject = mock.mockLogsForObject + o.ConsumeRequestFn = mock.mockConsumeRequest + o.MaxFollowConcurrency = 2 + o.Follow = true + return o + }, + expectedErr: "you are attempting to follow 3 log streams, but maximum allowed concurrency is 2, use --max-log-requests to increase the limit", + }, + { + name: "fail if LogsForObject fails", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + o := NewLogsOptions(streams, false) + o.LogsForObject = func(restClientGetter genericclioptions.RESTClientGetter, object, options runtime.Object, timeout time.Duration, allContainers bool) ([]restclient.ResponseWrapper, error) { + return nil, errors.New("Error from the LogsForObject") + } + return o + }, + expectedErr: "Error from the LogsForObject", + }, + { + name: "fail to get logs, if ConsumeRequestFn fails", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + mock := &logTestMock{ + logsForObjectRequests: []restclient.ResponseWrapper{ + &responseWrapperMock{}, + &responseWrapperMock{}, + }, + } + + o := NewLogsOptions(streams, false) + o.LogsForObject = mock.mockLogsForObject + o.ConsumeRequestFn = func(req restclient.ResponseWrapper, out io.Writer) error { + return errors.New("Error from the ConsumeRequestFn") + } + return o + }, + expectedErr: "Error from the ConsumeRequestFn", + }, + { + name: "fail to follow logs from multiple requests, if ConsumeRequestFn fails", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + wg := &sync.WaitGroup{} + mock := &logTestMock{ + logsForObjectRequests: []restclient.ResponseWrapper{ + &responseWrapperMock{}, + &responseWrapperMock{}, + &responseWrapperMock{}, + }, + wg: wg, + } + wg.Add(3) + + o := NewLogsOptions(streams, false) + o.LogsForObject = mock.mockLogsForObject + o.ConsumeRequestFn = func(req restclient.ResponseWrapper, out io.Writer) error { + return errors.New("Error from the ConsumeRequestFn") + } + o.Follow = true + return o + }, + expectedErr: "Error from the ConsumeRequestFn", + }, + { + name: "fail to follow logs, if ConsumeRequestFn fails", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + mock := &logTestMock{ + logsForObjectRequests: []restclient.ResponseWrapper{&responseWrapperMock{}}, + } + + o := NewLogsOptions(streams, false) + o.LogsForObject = mock.mockLogsForObject + o.ConsumeRequestFn = func(req restclient.ResponseWrapper, out io.Writer) error { + return errors.New("Error from the ConsumeRequestFn") + } + o.Follow = true + return o + }, + expectedErr: "Error from the ConsumeRequestFn", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + opts := test.opts(streams) + opts.Namespace = "test" + opts.Object = testPod() + opts.Options = &corev1.PodLogOptions{} + err := opts.RunLogs() + + if err == nil && len(test.expectedErr) > 0 { + t.Fatalf("expected error %q, got none", test.expectedErr) + } + + if err != nil && !strings.Contains(err.Error(), test.expectedErr) { + t.Errorf("%s: expected to find:\n\t%s\nfound:\n\t%s\n", test.name, test.expectedErr, err.Error()) + } + + bufStr := buf.String() + if test.expectedOutSubstrings != nil { + for _, substr := range test.expectedOutSubstrings { + if !strings.Contains(bufStr, substr) { + t.Errorf("%s: expected to contain %#v. Output: %#v", test.name, substr, bufStr) + } + } + } + }) + } +} + +func testPod() *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "test", ResourceVersion: "10"}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + DNSPolicy: corev1.DNSClusterFirst, + Containers: []corev1.Container{ + { + Name: "bar", + }, + }, + }, + } +} + +func TestValidateLogOptions(t *testing.T) { + f := cmdtesting.NewTestFactory() + defer f.Cleanup() + f.WithNamespace("") + + tests := []struct { + name string + args []string + opts func(genericclioptions.IOStreams) *LogsOptions + expected string + }{ + { + name: "since & since-time", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + o := NewLogsOptions(streams, false) + o.SinceSeconds = time.Hour + o.SinceTime = "2006-01-02T15:04:05Z" + + var err error + o.Options, err = o.ToLogOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + return o + }, + args: []string{"foo"}, + expected: "at most one of `sinceTime` or `sinceSeconds` may be specified", + }, + { + name: "negative since-time", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + o := NewLogsOptions(streams, false) + o.SinceSeconds = -1 * time.Second + + var err error + o.Options, err = o.ToLogOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + return o + }, + args: []string{"foo"}, + expected: "must be greater than 0", + }, + { + name: "negative limit-bytes", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + o := NewLogsOptions(streams, false) + o.LimitBytes = -100 + + var err error + o.Options, err = o.ToLogOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + return o + }, + args: []string{"foo"}, + expected: "must be greater than 0", + }, + { + name: "negative tail", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + o := NewLogsOptions(streams, false) + o.Tail = -100 + + var err error + o.Options, err = o.ToLogOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + return o + }, + args: []string{"foo"}, + expected: "--tail must be greater than or equal to -1", + }, + { + name: "container name combined with --all-containers", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + o := NewLogsOptions(streams, true) + o.Container = "my-container" + + var err error + o.Options, err = o.ToLogOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + return o + }, + args: []string{"my-pod", "my-container"}, + expected: "--all-containers=true should not be specified with container", + }, + { + name: "container name combined with second argument", + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + o := NewLogsOptions(streams, false) + o.Container = "my-container" + o.ContainerNameSpecified = true + + var err error + o.Options, err = o.ToLogOptions() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + return o + }, + args: []string{"my-pod", "my-container"}, + expected: "only one of -c or an inline", + }, + } + for _, test := range tests { + streams := genericclioptions.NewTestIOStreamsDiscard() + + o := test.opts(streams) + o.Resources = test.args + + err := o.Validate() + if err == nil { + t.Fatalf("expected error %q, got none", test.expected) + } + + if !strings.Contains(err.Error(), test.expected) { + t.Errorf("%s: expected to find:\n\t%s\nfound:\n\t%s\n", test.name, test.expected, err.Error()) + } + } +} + +func TestLogComplete(t *testing.T) { + f := cmdtesting.NewTestFactory() + defer f.Cleanup() + + tests := []struct { + name string + args []string + opts func(genericclioptions.IOStreams) *LogsOptions + expected string + }{ + { + name: "One args case", + args: []string{"foo"}, + opts: func(streams genericclioptions.IOStreams) *LogsOptions { + o := NewLogsOptions(streams, false) + o.Selector = "foo" + return o + }, + expected: "only a selector (-l) or a POD name is allowed", + }, + } + for _, test := range tests { + cmd := NewCmdLogs(f, genericclioptions.NewTestIOStreamsDiscard()) + out := "" + + // checkErr breaks tests in case of errors, plus we just + // need to check errors returned by the command validation + o := test.opts(genericclioptions.NewTestIOStreamsDiscard()) + err := o.Complete(f, cmd, test.args) + if err == nil { + t.Fatalf("expected error %q, got none", test.expected) + } + + out = err.Error() + if !strings.Contains(out, test.expected) { + t.Errorf("%s: expected to find:\n\t%s\nfound:\n\t%s\n", test.name, test.expected, out) + } + } +} + +func TestDefaultConsumeRequest(t *testing.T) { + tests := []struct { + name string + request restclient.ResponseWrapper + expectedErr string + expectedOut string + }{ + { + name: "error from request stream", + request: &responseWrapperMock{ + err: errors.New("err from the stream"), + }, + expectedErr: "err from the stream", + }, + { + name: "error while reading", + request: &responseWrapperMock{ + data: iotest.TimeoutReader(strings.NewReader("Some data")), + }, + expectedErr: iotest.ErrTimeout.Error(), + expectedOut: "Some data", + }, + { + name: "read with empty string", + request: &responseWrapperMock{ + data: strings.NewReader(""), + }, + expectedOut: "", + }, + { + name: "read without new lines", + request: &responseWrapperMock{ + data: strings.NewReader("some string without a new line"), + }, + expectedOut: "some string without a new line", + }, + { + name: "read with newlines in the middle", + request: &responseWrapperMock{ + data: strings.NewReader("foo\nbar"), + }, + expectedOut: "foo\nbar", + }, + { + name: "read with newline at the end", + request: &responseWrapperMock{ + data: strings.NewReader("foo\n"), + }, + expectedOut: "foo\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + buf := &bytes.Buffer{} + err := DefaultConsumeRequest(test.request, buf) + + if err != nil && !strings.Contains(err.Error(), test.expectedErr) { + t.Errorf("%s: expected to find:\n\t%s\nfound:\n\t%s\n", test.name, test.expectedErr, err.Error()) + } + + if buf.String() != test.expectedOut { + t.Errorf("%s: did not get expected log content. Got: %s", test.name, buf.String()) + } + }) + } +} + +type responseWrapperMock struct { + data io.Reader + err error +} + +func (r *responseWrapperMock) DoRaw() ([]byte, error) { + data, _ := ioutil.ReadAll(r.data) + return data, r.err +} + +func (r *responseWrapperMock) Stream() (io.ReadCloser, error) { + return ioutil.NopCloser(r.data), r.err +} + +type logTestMock struct { + logsForObjectRequests []restclient.ResponseWrapper + + // We need a WaitGroup in some test cases to make sure that we fetch logs concurrently. + // These test cases will finish successfully without the WaitGroup, but the WaitGroup + // will help us to identify regression when someone accidentally changes + // concurrent fetching to sequential + wg *sync.WaitGroup +} + +func (l *logTestMock) mockConsumeRequest(request restclient.ResponseWrapper, out io.Writer) error { + readCloser, err := request.Stream() + if err != nil { + return err + } + defer readCloser.Close() + + // Just copy everything for a test sake + _, err = io.Copy(out, readCloser) + if l.wg != nil { + l.wg.Done() + l.wg.Wait() + } + return err +} + +func (l *logTestMock) mockLogsForObject(restClientGetter genericclioptions.RESTClientGetter, object, options runtime.Object, timeout time.Duration, allContainers bool) ([]restclient.ResponseWrapper, error) { + switch object.(type) { + case *corev1.Pod: + _, ok := options.(*corev1.PodLogOptions) + if !ok { + return nil, errors.New("provided options object is not a PodLogOptions") + } + + return l.logsForObjectRequests, nil + default: + return nil, fmt.Errorf("cannot get the logs from %T", object) + } +} diff --git a/pkg/cmd/options/options.go b/pkg/cmd/options/options.go new file mode 100644 index 000000000..a4e22d097 --- /dev/null +++ b/pkg/cmd/options/options.go @@ -0,0 +1,55 @@ +/* +Copyright 2016 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 options + +import ( + "io" + + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/spf13/cobra" +) + +var ( + optionsExample = templates.Examples(i18n.T(` + # Print flags inherited by all commands + kubectl options`)) +) + +// NewCmdOptions implements the options command +func NewCmdOptions(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "options", + Short: i18n.T("Print the list of flags inherited by all commands"), + Long: "Print the list of flags inherited by all commands", + Example: optionsExample, + Run: func(cmd *cobra.Command, args []string) { + cmd.Usage() + }, + } + + // The `options` command needs write its output to the `out` stream + // (typically stdout). Without calling SetOutput here, the Usage() + // function call will fall back to stderr. + // + // See https://github.com/kubernetes/kubernetes/pull/46394 for details. + cmd.SetOutput(out) + + templates.UseOptionsTemplates(cmd) + return cmd +} diff --git a/pkg/cmd/patch/patch.go b/pkg/cmd/patch/patch.go new file mode 100644 index 000000000..e44ebdca2 --- /dev/null +++ b/pkg/cmd/patch/patch.go @@ -0,0 +1,316 @@ +/* +Copyright 2014 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 patch + +import ( + "fmt" + "reflect" + "strings" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var patchTypes = map[string]types.PatchType{"json": types.JSONPatchType, "merge": types.MergePatchType, "strategic": types.StrategicMergePatchType} + +// PatchOptions is the start of the data required to perform the operation. As new fields are added, add them here instead of +// referencing the cmd.Flags() +type PatchOptions struct { + resource.FilenameOptions + + RecordFlags *genericclioptions.RecordFlags + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + Recorder genericclioptions.Recorder + + Local bool + PatchType string + Patch string + + namespace string + enforceNamespace bool + dryRun bool + outputFormat string + args []string + builder *resource.Builder + unstructuredClientForMapping func(mapping *meta.RESTMapping) (resource.RESTClient, error) + + genericclioptions.IOStreams +} + +var ( + patchLong = templates.LongDesc(i18n.T(` + Update field(s) of a resource using strategic merge patch, a JSON merge patch, or a JSON patch. + + JSON and YAML formats are accepted.`)) + + patchExample = templates.Examples(i18n.T(` + # Partially update a node using a strategic merge patch. Specify the patch as JSON. + kubectl patch node k8s-node-1 -p '{"spec":{"unschedulable":true}}' + + # Partially update a node using a strategic merge patch. Specify the patch as YAML. + kubectl patch node k8s-node-1 -p $'spec:\n unschedulable: true' + + # Partially update a node identified by the type and name specified in "node.json" using strategic merge patch. + kubectl patch -f node.json -p '{"spec":{"unschedulable":true}}' + + # Update a container's image; spec.containers[*].name is required because it's a merge key. + kubectl patch pod valid-pod -p '{"spec":{"containers":[{"name":"kubernetes-serve-hostname","image":"new image"}]}}' + + # Update a container's image using a json patch with positional arrays. + kubectl patch pod valid-pod --type='json' -p='[{"op": "replace", "path": "/spec/containers/0/image", "value":"new image"}]'`)) +) + +func NewPatchOptions(ioStreams genericclioptions.IOStreams) *PatchOptions { + return &PatchOptions{ + RecordFlags: genericclioptions.NewRecordFlags(), + Recorder: genericclioptions.NoopRecorder{}, + PrintFlags: genericclioptions.NewPrintFlags("patched").WithTypeSetter(scheme.Scheme), + IOStreams: ioStreams, + } +} + +func NewCmdPatch(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewPatchOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "patch (-f FILENAME | TYPE NAME) -p PATCH", + DisableFlagsInUseLine: true, + Short: i18n.T("Update field(s) of a resource using strategic merge patch"), + Long: patchLong, + Example: patchExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunPatch()) + }, + } + + o.RecordFlags.AddFlags(cmd) + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().StringVarP(&o.Patch, "patch", "p", "", "The patch to be applied to the resource JSON file.") + cmd.MarkFlagRequired("patch") + cmd.Flags().StringVar(&o.PatchType, "type", "strategic", fmt.Sprintf("The type of patch being provided; one of %v", sets.StringKeySet(patchTypes).List())) + cmdutil.AddDryRunFlag(cmd) + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, "identifying the resource to update") + cmd.Flags().BoolVar(&o.Local, "local", o.Local, "If true, patch will operate on the content of the file, not the server-side resource.") + + return cmd +} + +func (o *PatchOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.outputFormat = cmdutil.GetFlagString(cmd, "output") + o.dryRun = cmdutil.GetFlagBool(cmd, "dry-run") + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + if o.dryRun { + o.PrintFlags.Complete("%s (dry run)") + } + + return o.PrintFlags.ToPrinter() + } + + o.namespace, o.enforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + o.args = args + o.builder = f.NewBuilder() + o.unstructuredClientForMapping = f.UnstructuredClientForMapping + + return nil +} + +func (o *PatchOptions) Validate() error { + if o.Local && len(o.args) != 0 { + return fmt.Errorf("cannot specify --local and server resources") + } + if len(o.Patch) == 0 { + return fmt.Errorf("must specify -p to patch") + } + if len(o.PatchType) != 0 { + if _, ok := patchTypes[strings.ToLower(o.PatchType)]; !ok { + return fmt.Errorf("--type must be one of %v, not %q", sets.StringKeySet(patchTypes).List(), o.PatchType) + } + } + + return nil +} + +func (o *PatchOptions) RunPatch() error { + patchType := types.StrategicMergePatchType + if len(o.PatchType) != 0 { + patchType = patchTypes[strings.ToLower(o.PatchType)] + } + + patchBytes, err := yaml.ToJSON([]byte(o.Patch)) + if err != nil { + return fmt.Errorf("unable to parse %q: %v", o.Patch, err) + } + + r := o.builder. + Unstructured(). + ContinueOnError(). + LocalParam(o.Local). + NamespaceParam(o.namespace).DefaultNamespace(). + FilenameParam(o.enforceNamespace, &o.FilenameOptions). + ResourceTypeOrNameArgs(false, o.args...). + Flatten(). + Do() + err = r.Err() + if err != nil { + return err + } + + count := 0 + err = r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + count++ + name, namespace := info.Name, info.Namespace + + if !o.Local && !o.dryRun { + mapping := info.ResourceMapping() + client, err := o.unstructuredClientForMapping(mapping) + if err != nil { + return err + } + + helper := resource.NewHelper(client, mapping) + patchedObj, err := helper.Patch(namespace, name, patchType, patchBytes, nil) + if err != nil { + return err + } + + didPatch := !reflect.DeepEqual(info.Object, patchedObj) + + // if the recorder makes a change, compute and create another patch + if mergePatch, err := o.Recorder.MakeRecordMergePatch(patchedObj); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } else if len(mergePatch) > 0 { + if recordedObj, err := helper.Patch(info.Namespace, info.Name, types.MergePatchType, mergePatch, nil); err != nil { + klog.V(4).Infof("error recording reason: %v", err) + } else { + patchedObj = recordedObj + } + } + + printer, err := o.ToPrinter(patchOperation(didPatch)) + if err != nil { + return err + } + return printer.PrintObj(patchedObj, o.Out) + } + + originalObjJS, err := runtime.Encode(unstructured.UnstructuredJSONScheme, info.Object) + if err != nil { + return err + } + + originalPatchedObjJS, err := getPatchedJSON(patchType, originalObjJS, patchBytes, info.Object.GetObjectKind().GroupVersionKind(), scheme.Scheme) + if err != nil { + return err + } + + targetObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, originalPatchedObjJS) + if err != nil { + return err + } + + didPatch := !reflect.DeepEqual(info.Object, targetObj) + printer, err := o.ToPrinter(patchOperation(didPatch)) + if err != nil { + return err + } + return printer.PrintObj(targetObj, o.Out) + }) + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("no objects passed to patch") + } + return nil +} + +func getPatchedJSON(patchType types.PatchType, originalJS, patchJS []byte, gvk schema.GroupVersionKind, creater runtime.ObjectCreater) ([]byte, error) { + switch patchType { + case types.JSONPatchType: + patchObj, err := jsonpatch.DecodePatch(patchJS) + if err != nil { + return nil, err + } + bytes, err := patchObj.Apply(originalJS) + // TODO: This is pretty hacky, we need a better structured error from the json-patch + if err != nil && strings.Contains(err.Error(), "doc is missing key") { + msg := err.Error() + ix := strings.Index(msg, "key:") + key := msg[ix+5:] + return bytes, fmt.Errorf("Object to be patched is missing field (%s)", key) + } + return bytes, err + + case types.MergePatchType: + return jsonpatch.MergePatch(originalJS, patchJS) + + case types.StrategicMergePatchType: + // get a typed object for this GVK if we need to apply a strategic merge patch + obj, err := creater.New(gvk) + if err != nil { + return nil, fmt.Errorf("cannot apply strategic merge patch for %s locally, try --type merge", gvk.String()) + } + return strategicpatch.StrategicMergePatch(originalJS, patchJS, obj) + + default: + // only here as a safety net - go-restful filters content-type + return nil, fmt.Errorf("unknown Content-Type header for patch: %v", patchType) + } +} + +func patchOperation(didPatch bool) string { + if didPatch { + return "patched" + } + return "patched (no change)" +} diff --git a/pkg/cmd/patch/patch_test.go b/pkg/cmd/patch/patch_test.go new file mode 100644 index 000000000..33ba14649 --- /dev/null +++ b/pkg/cmd/patch/patch_test.go @@ -0,0 +1,192 @@ +/* +Copyright 2015 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 patch + +import ( + "net/http" + "strings" + "testing" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestPatchObject(t *testing.T) { + _, svc, _ := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/services/frontend" && (m == "PATCH" || m == "GET"): + obj := svc.Items[0] + + // ensure patched object reflects successful + // patch edits from the client + if m == "PATCH" { + obj.Spec.Type = "NodePort" + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &obj)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + stream, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdPatch(tf, stream) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("patch", `{"spec":{"type":"NodePort"}}`) + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{"services/frontend"}) + + // uses the name from the response + if buf.String() != "service/baz\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestPatchObjectFromFile(t *testing.T) { + _, svc, _ := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/services/frontend" && (m == "PATCH" || m == "GET"): + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + stream, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdPatch(tf, stream) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("patch", `{"spec":{"type":"NodePort"}}`) + cmd.Flags().Set("output", "name") + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/frontend-service.yaml") + cmd.Run(cmd, []string{}) + + // uses the name from the response + if buf.String() != "service/baz\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestPatchNoop(t *testing.T) { + _, svc, _ := cmdtesting.TestData() + getObject := &svc.Items[0] + patchObject := &svc.Items[0] + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/services/frontend" && m == "PATCH": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, patchObject)}, nil + case p == "/namespaces/test/services/frontend" && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, getObject)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + + // Patched + { + patchObject = patchObject.DeepCopy() + if patchObject.Annotations == nil { + patchObject.Annotations = map[string]string{} + } + patchObject.Annotations["foo"] = "bar" + stream, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdPatch(tf, stream) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("patch", `{"metadata":{"annotations":{"foo":"bar"}}}`) + cmd.Run(cmd, []string{"services", "frontend"}) + if buf.String() != "service/baz patched\n" { + t.Errorf("unexpected output: %s", buf.String()) + } + } +} + +func TestPatchObjectFromFileOutput(t *testing.T) { + _, svc, _ := cmdtesting.TestData() + + svcCopy := svc.Items[0].DeepCopy() + if svcCopy.Labels == nil { + svcCopy.Labels = map[string]string{} + } + svcCopy.Labels["post-patch"] = "post-patch-value" + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/services/frontend" && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + case p == "/namespaces/test/services/frontend" && m == "PATCH": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, svcCopy)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + stream, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdPatch(tf, stream) + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("patch", `{"spec":{"type":"NodePort"}}`) + cmd.Flags().Set("output", "yaml") + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/frontend-service.yaml") + cmd.Run(cmd, []string{}) + + t.Log(buf.String()) + // make sure the value returned by the server is used + if !strings.Contains(buf.String(), "post-patch: post-patch-value") { + t.Errorf("unexpected output: %s", buf.String()) + } +} diff --git a/pkg/cmd/plugin/plugin.go b/pkg/cmd/plugin/plugin.go new file mode 100644 index 000000000..486e0e39f --- /dev/null +++ b/pkg/cmd/plugin/plugin.go @@ -0,0 +1,275 @@ +/* +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 plugin + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + pluginLong = templates.LongDesc(` + Provides utilities for interacting with plugins. + + Plugins provide extended functionality that is not part of the major command-line distribution. + Please refer to the documentation and examples for more information about how write your own plugins.`) + + pluginListLong = templates.LongDesc(` + List all available plugin files on a user's PATH. + + Available plugin files are those that are: + - executable + - anywhere on the user's PATH + - begin with "kubectl-" +`) + + ValidPluginFilenamePrefixes = []string{"kubectl"} +) + +func NewCmdPlugin(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "plugin [flags]", + DisableFlagsInUseLine: true, + Short: i18n.T("Provides utilities for interacting with plugins."), + Long: pluginLong, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.DefaultSubCommandRun(streams.ErrOut)(cmd, args) + }, + } + + cmd.AddCommand(NewCmdPluginList(f, streams)) + return cmd +} + +type PluginListOptions struct { + Verifier PathVerifier + NameOnly bool + + PluginPaths []string + + genericclioptions.IOStreams +} + +// NewCmdPluginList provides a way to list all plugin executables visible to kubectl +func NewCmdPluginList(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := &PluginListOptions{ + IOStreams: streams, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "list all visible plugin executables on a user's PATH", + Long: pluginListLong, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(cmd)) + cmdutil.CheckErr(o.Run()) + }, + } + + cmd.Flags().BoolVar(&o.NameOnly, "name-only", o.NameOnly, "If true, display only the binary name of each plugin, rather than its full path") + return cmd +} + +func (o *PluginListOptions) Complete(cmd *cobra.Command) error { + o.Verifier = &CommandOverrideVerifier{ + root: cmd.Root(), + seenPlugins: make(map[string]string, 0), + } + + o.PluginPaths = filepath.SplitList(os.Getenv("PATH")) + return nil +} + +func (o *PluginListOptions) Run() error { + pluginsFound := false + isFirstFile := true + pluginErrors := []error{} + pluginWarnings := 0 + + for _, dir := range uniquePathsList(o.PluginPaths) { + files, err := ioutil.ReadDir(dir) + if err != nil { + if _, ok := err.(*os.PathError); ok { + fmt.Fprintf(o.ErrOut, "Unable read directory %q from your PATH: %v. Skipping...", dir, err) + continue + } + + pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to read directory %q in your PATH: %v", dir, err)) + continue + } + + for _, f := range files { + if f.IsDir() { + continue + } + if !hasValidPrefix(f.Name(), ValidPluginFilenamePrefixes) { + continue + } + + if isFirstFile { + fmt.Fprintf(o.ErrOut, "The following compatible plugins are available:\n\n") + pluginsFound = true + isFirstFile = false + } + + pluginPath := f.Name() + if !o.NameOnly { + pluginPath = filepath.Join(dir, pluginPath) + } + + fmt.Fprintf(o.Out, "%s\n", pluginPath) + if errs := o.Verifier.Verify(filepath.Join(dir, f.Name())); len(errs) != 0 { + for _, err := range errs { + fmt.Fprintf(o.ErrOut, " - %s\n", err) + pluginWarnings++ + } + } + } + } + + if !pluginsFound { + pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to find any kubectl plugins in your PATH")) + } + + if pluginWarnings > 0 { + if pluginWarnings == 1 { + pluginErrors = append(pluginErrors, fmt.Errorf("error: one plugin warning was found")) + } else { + pluginErrors = append(pluginErrors, fmt.Errorf("error: %v plugin warnings were found", pluginWarnings)) + } + } + if len(pluginErrors) > 0 { + fmt.Fprintln(o.ErrOut) + errs := bytes.NewBuffer(nil) + for _, e := range pluginErrors { + fmt.Fprintln(errs, e) + } + return fmt.Errorf("%s", errs.String()) + } + + return nil +} + +// pathVerifier receives a path and determines if it is valid or not +type PathVerifier interface { + // Verify determines if a given path is valid + Verify(path string) []error +} + +type CommandOverrideVerifier struct { + root *cobra.Command + seenPlugins map[string]string +} + +// Verify implements PathVerifier and determines if a given path +// is valid depending on whether or not it overwrites an existing +// kubectl command path, or a previously seen plugin. +func (v *CommandOverrideVerifier) Verify(path string) []error { + if v.root == nil { + return []error{fmt.Errorf("unable to verify path with nil root")} + } + + // extract the plugin binary name + segs := strings.Split(path, "/") + binName := segs[len(segs)-1] + + cmdPath := strings.Split(binName, "-") + if len(cmdPath) > 1 { + // the first argument is always "kubectl" for a plugin binary + cmdPath = cmdPath[1:] + } + + errors := []error{} + + if isExec, err := isExecutable(path); err == nil && !isExec { + errors = append(errors, fmt.Errorf("warning: %s identified as a kubectl plugin, but it is not executable", path)) + } else if err != nil { + errors = append(errors, fmt.Errorf("error: unable to identify %s as an executable file: %v", path, err)) + } + + if existingPath, ok := v.seenPlugins[binName]; ok { + errors = append(errors, fmt.Errorf("warning: %s is overshadowed by a similarly named plugin: %s", path, existingPath)) + } else { + v.seenPlugins[binName] = path + } + + if cmd, _, err := v.root.Find(cmdPath); err == nil { + errors = append(errors, fmt.Errorf("warning: %s overwrites existing command: %q", binName, cmd.CommandPath())) + } + + return errors +} + +func isExecutable(fullPath string) (bool, error) { + info, err := os.Stat(fullPath) + if err != nil { + return false, err + } + + if runtime.GOOS == "windows" { + fileExt := strings.ToLower(filepath.Ext(fullPath)) + + switch fileExt { + case ".bat", ".cmd", ".com", ".exe", ".ps1": + return true, nil + } + return false, nil + } + + if m := info.Mode(); !m.IsDir() && m&0111 != 0 { + return true, nil + } + + return false, nil +} + +// uniquePathsList deduplicates a given slice of strings without +// sorting or otherwise altering its order in any way. +func uniquePathsList(paths []string) []string { + seen := map[string]bool{} + newPaths := []string{} + for _, p := range paths { + if seen[p] { + continue + } + seen[p] = true + newPaths = append(newPaths, p) + } + return newPaths +} + +func hasValidPrefix(filepath string, validPrefixes []string) bool { + for _, prefix := range validPrefixes { + if !strings.HasPrefix(filepath, prefix+"-") { + continue + } + return true + } + return false +} diff --git a/pkg/cmd/plugin/plugin_test.go b/pkg/cmd/plugin/plugin_test.go new file mode 100644 index 000000000..5f6c0d5b7 --- /dev/null +++ b/pkg/cmd/plugin/plugin_test.go @@ -0,0 +1,197 @@ +/* +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 plugin + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "strings" + "testing" + + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +func TestPluginPathsAreUnaltered(t *testing.T) { + tempDir, err := ioutil.TempDir(os.TempDir(), "test-cmd-plugins") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tempDir2, err := ioutil.TempDir(os.TempDir(), "test-cmd-plugins2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // cleanup + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + panic(fmt.Errorf("unexpected cleanup error: %v", err)) + } + if err := os.RemoveAll(tempDir2); err != nil { + panic(fmt.Errorf("unexpected cleanup error: %v", err)) + } + }() + + ioStreams, _, _, errOut := genericclioptions.NewTestIOStreams() + verifier := newFakePluginPathVerifier() + pluginPaths := []string{tempDir, tempDir2} + o := &PluginListOptions{ + Verifier: verifier, + IOStreams: ioStreams, + + PluginPaths: pluginPaths, + } + + // write at least one valid plugin file + if _, err := ioutil.TempFile(tempDir, "kubectl-"); err != nil { + t.Fatalf("unexpected error %v", err) + } + if _, err := ioutil.TempFile(tempDir2, "kubectl-"); err != nil { + t.Fatalf("unexpected error %v", err) + } + + if err := o.Run(); err != nil { + t.Fatalf("unexpected error %v - %v", err, errOut.String()) + } + + // ensure original paths remain unaltered + if len(verifier.seenUnsorted) != len(pluginPaths) { + t.Fatalf("saw unexpected plugin paths. Expecting %v, got %v", pluginPaths, verifier.seenUnsorted) + } + for actual := range verifier.seenUnsorted { + if !strings.HasPrefix(verifier.seenUnsorted[actual], pluginPaths[actual]) { + t.Fatalf("expected PATH slice to be unaltered. Expecting %v, but got %v", pluginPaths[actual], verifier.seenUnsorted[actual]) + } + } +} + +func TestPluginPathsAreValid(t *testing.T) { + tempDir, err := ioutil.TempDir(os.TempDir(), "test-cmd-plugins") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // cleanup + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + panic(fmt.Errorf("unexpected cleanup error: %v", err)) + } + }() + + tc := []struct { + name string + pluginPaths []string + pluginFile func() (*os.File, error) + verifier *fakePluginPathVerifier + expectVerifyErrors []error + expectErr string + }{ + { + name: "ensure no plugins found if no files begin with kubectl- prefix", + pluginPaths: []string{tempDir}, + verifier: newFakePluginPathVerifier(), + pluginFile: func() (*os.File, error) { + return ioutil.TempFile(tempDir, "notkubectl-") + }, + expectErr: "unable to find any kubectl plugins in your PATH", + }, + { + name: "ensure de-duplicated plugin-paths slice", + pluginPaths: []string{tempDir, tempDir}, + verifier: newFakePluginPathVerifier(), + pluginFile: func() (*os.File, error) { + return ioutil.TempFile(tempDir, "kubectl-") + }, + }, + } + + for _, test := range tc { + t.Run(test.name, func(t *testing.T) { + ioStreams, _, _, errOut := genericclioptions.NewTestIOStreams() + o := &PluginListOptions{ + Verifier: test.verifier, + IOStreams: ioStreams, + + PluginPaths: test.pluginPaths, + } + + // create files + if test.pluginFile != nil { + if _, err := test.pluginFile(); err != nil { + t.Fatalf("unexpected error creating plugin file: %v", err) + } + } + + for _, expected := range test.expectVerifyErrors { + for _, actual := range test.verifier.errors { + if expected != actual { + t.Fatalf("unexpected error: expected %v, but got %v", expected, actual) + } + } + } + + err := o.Run() + if err == nil && len(test.expectErr) > 0 { + t.Fatalf("unexpected non-error: expecting %v", test.expectErr) + } + if err != nil && len(test.expectErr) == 0 { + t.Fatalf("unexpected error: %v - %v", err, errOut.String()) + } + if err == nil { + return + } + + allErrs := bytes.NewBuffer(errOut.Bytes()) + if _, err := allErrs.WriteString(err.Error()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(test.expectErr) > 0 { + if !strings.Contains(allErrs.String(), test.expectErr) { + t.Fatalf("unexpected error: expected %v, but got %v", test.expectErr, allErrs.String()) + } + } + }) + } +} + +type duplicatePathError struct { + path string +} + +func (d *duplicatePathError) Error() string { + return fmt.Sprintf("path %q already visited", d.path) +} + +type fakePluginPathVerifier struct { + errors []error + seen map[string]bool + seenUnsorted []string +} + +func (f *fakePluginPathVerifier) Verify(path string) []error { + if f.seen[path] { + err := &duplicatePathError{path} + f.errors = append(f.errors, err) + return []error{err} + } + f.seen[path] = true + f.seenUnsorted = append(f.seenUnsorted, path) + return nil +} + +func newFakePluginPathVerifier() *fakePluginPathVerifier { + return &fakePluginPathVerifier{seen: make(map[string]bool)} +} diff --git a/pkg/cmd/plugin/testdata/kubectl-foo b/pkg/cmd/plugin/testdata/kubectl-foo new file mode 100755 index 000000000..651b7662d --- /dev/null +++ b/pkg/cmd/plugin/testdata/kubectl-foo @@ -0,0 +1,3 @@ +#!/bin/bash + +echo "I am plugin foo" diff --git a/pkg/cmd/plugin/testdata/kubectl-version b/pkg/cmd/plugin/testdata/kubectl-version new file mode 100755 index 000000000..3718966b6 --- /dev/null +++ b/pkg/cmd/plugin/testdata/kubectl-version @@ -0,0 +1,4 @@ +#!/bin/bash + +# This plugin is a no-op and is used to test plugins +# that overshadow existing kubectl commands diff --git a/pkg/cmd/portforward/portforward.go b/pkg/cmd/portforward/portforward.go new file mode 100644 index 000000000..dc900f611 --- /dev/null +++ b/pkg/cmd/portforward/portforward.go @@ -0,0 +1,341 @@ +/* +Copyright 2014 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 portforward + +import ( + "fmt" + "net/http" + "net/url" + "os" + "os/signal" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/kubernetes/scheme" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// PortForwardOptions contains all the options for running the port-forward cli command. +type PortForwardOptions struct { + Namespace string + PodName string + RESTClient *restclient.RESTClient + Config *restclient.Config + PodClient corev1client.PodsGetter + Address []string + Ports []string + PortForwarder portForwarder + StopChannel chan struct{} + ReadyChannel chan struct{} +} + +var ( + portforwardLong = templates.LongDesc(i18n.T(` + Forward one or more local ports to a pod. This command requires the node to have 'socat' installed. + + Use resource type/name such as deployment/mydeployment to select a pod. Resource type defaults to 'pod' if omitted. + + If there are multiple pods matching the criteria, a pod will be selected automatically. The + forwarding session ends when the selected pod terminates, and rerun of the command is needed + to resume forwarding.`)) + + portforwardExample = templates.Examples(i18n.T(` + # Listen on ports 5000 and 6000 locally, forwarding data to/from ports 5000 and 6000 in the pod + kubectl port-forward pod/mypod 5000 6000 + + # Listen on ports 5000 and 6000 locally, forwarding data to/from ports 5000 and 6000 in a pod selected by the deployment + kubectl port-forward deployment/mydeployment 5000 6000 + + # Listen on ports 5000 and 6000 locally, forwarding data to/from ports 5000 and 6000 in a pod selected by the service + kubectl port-forward service/myservice 5000 6000 + + # Listen on port 8888 locally, forwarding to 5000 in the pod + kubectl port-forward pod/mypod 8888:5000 + + # Listen on port 8888 on all addresses, forwarding to 5000 in the pod + kubectl port-forward --address 0.0.0.0 pod/mypod 8888:5000 + + # Listen on port 8888 on localhost and selected IP, forwarding to 5000 in the pod + kubectl port-forward --address localhost,10.19.21.23 pod/mypod 8888:5000 + + # Listen on a random port locally, forwarding to 5000 in the pod + kubectl port-forward pod/mypod :5000`)) +) + +const ( + // Amount of time to wait until at least one pod is running + defaultPodPortForwardWaitTimeout = 60 * time.Second +) + +func NewCmdPortForward(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + opts := &PortForwardOptions{ + PortForwarder: &defaultPortForwarder{ + IOStreams: streams, + }, + } + cmd := &cobra.Command{ + Use: "port-forward TYPE/NAME [options] [LOCAL_PORT:]REMOTE_PORT [...[LOCAL_PORT_N:]REMOTE_PORT_N]", + DisableFlagsInUseLine: true, + Short: i18n.T("Forward one or more local ports to a pod"), + Long: portforwardLong, + Example: portforwardExample, + Run: func(cmd *cobra.Command, args []string) { + if err := opts.Complete(f, cmd, args); err != nil { + cmdutil.CheckErr(err) + } + if err := opts.Validate(); err != nil { + cmdutil.CheckErr(cmdutil.UsageErrorf(cmd, "%v", err.Error())) + } + if err := opts.RunPortForward(); err != nil { + cmdutil.CheckErr(err) + } + }, + } + cmdutil.AddPodRunningTimeoutFlag(cmd, defaultPodPortForwardWaitTimeout) + cmd.Flags().StringSliceVar(&opts.Address, "address", []string{"localhost"}, "Addresses to listen on (comma separated). Only accepts IP addresses or localhost as a value. When localhost is supplied, kubectl will try to bind on both 127.0.0.1 and ::1 and will fail if neither of these addresses are available to bind.") + // TODO support UID + return cmd +} + +type portForwarder interface { + ForwardPorts(method string, url *url.URL, opts PortForwardOptions) error +} + +type defaultPortForwarder struct { + genericclioptions.IOStreams +} + +func (f *defaultPortForwarder) ForwardPorts(method string, url *url.URL, opts PortForwardOptions) error { + transport, upgrader, err := spdy.RoundTripperFor(opts.Config) + if err != nil { + return err + } + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, method, url) + fw, err := portforward.NewOnAddresses(dialer, opts.Address, opts.Ports, opts.StopChannel, opts.ReadyChannel, f.Out, f.ErrOut) + if err != nil { + return err + } + return fw.ForwardPorts() +} + +// splitPort splits port string which is in form of [LOCAL PORT]:REMOTE PORT +// and returns local and remote ports separately +func splitPort(port string) (local, remote string) { + parts := strings.Split(port, ":") + if len(parts) == 2 { + return parts[0], parts[1] + } + + return parts[0], parts[0] +} + +// Translates service port to target port +// It rewrites ports as needed if the Service port declares targetPort. +// It returns an error when a named targetPort can't find a match in the pod, or the Service did not declare +// the port. +func translateServicePortToTargetPort(ports []string, svc corev1.Service, pod corev1.Pod) ([]string, error) { + var translated []string + for _, port := range ports { + localPort, remotePort := splitPort(port) + + portnum, err := strconv.Atoi(remotePort) + if err != nil { + svcPort, err := util.LookupServicePortNumberByName(svc, remotePort) + if err != nil { + return nil, err + } + portnum = int(svcPort) + + if localPort == remotePort { + localPort = strconv.Itoa(portnum) + } + } + containerPort, err := util.LookupContainerPortNumberByServicePort(svc, pod, int32(portnum)) + if err != nil { + // can't resolve a named port, or Service did not declare this port, return an error + return nil, err + } + + if int32(portnum) != containerPort { + translated = append(translated, fmt.Sprintf("%s:%d", localPort, containerPort)) + } else { + translated = append(translated, port) + } + } + return translated, nil +} + +// convertPodNamedPortToNumber converts named ports into port numbers +// It returns an error when a named port can't be found in the pod containers +func convertPodNamedPortToNumber(ports []string, pod corev1.Pod) ([]string, error) { + var converted []string + for _, port := range ports { + localPort, remotePort := splitPort(port) + + containerPortStr := remotePort + _, err := strconv.Atoi(remotePort) + if err != nil { + containerPort, err := util.LookupContainerPortNumberByName(pod, remotePort) + if err != nil { + return nil, err + } + + containerPortStr = strconv.Itoa(int(containerPort)) + } + + if localPort != remotePort { + converted = append(converted, fmt.Sprintf("%s:%s", localPort, containerPortStr)) + } else { + converted = append(converted, containerPortStr) + } + } + + return converted, nil +} + +// Complete completes all the required options for port-forward cmd. +func (o *PortForwardOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + if len(args) < 2 { + return cmdutil.UsageErrorf(cmd, "TYPE/NAME and list of ports are required for port-forward") + } + + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + builder := f.NewBuilder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + ContinueOnError(). + NamespaceParam(o.Namespace).DefaultNamespace() + + getPodTimeout, err := cmdutil.GetPodRunningTimeoutFlag(cmd) + if err != nil { + return cmdutil.UsageErrorf(cmd, err.Error()) + } + + resourceName := args[0] + builder.ResourceNames("pods", resourceName) + + obj, err := builder.Do().Object() + if err != nil { + return err + } + + forwardablePod, err := polymorphichelpers.AttachablePodForObjectFn(f, obj, getPodTimeout) + if err != nil { + return err + } + + o.PodName = forwardablePod.Name + + // handle service port mapping to target port if needed + switch t := obj.(type) { + case *corev1.Service: + o.Ports, err = translateServicePortToTargetPort(args[1:], *t, *forwardablePod) + if err != nil { + return err + } + default: + o.Ports, err = convertPodNamedPortToNumber(args[1:], *forwardablePod) + if err != nil { + return err + } + } + + clientset, err := f.KubernetesClientSet() + if err != nil { + return err + } + + o.PodClient = clientset.CoreV1() + + o.Config, err = f.ToRESTConfig() + if err != nil { + return err + } + o.RESTClient, err = f.RESTClient() + if err != nil { + return err + } + + o.StopChannel = make(chan struct{}, 1) + o.ReadyChannel = make(chan struct{}) + return nil +} + +// Validate validates all the required options for port-forward cmd. +func (o PortForwardOptions) Validate() error { + if len(o.PodName) == 0 { + return fmt.Errorf("pod name or resource type/name must be specified") + } + + if len(o.Ports) < 1 { + return fmt.Errorf("at least 1 PORT is required for port-forward") + } + + if o.PortForwarder == nil || o.PodClient == nil || o.RESTClient == nil || o.Config == nil { + return fmt.Errorf("client, client config, restClient, and portforwarder must be provided") + } + return nil +} + +// RunPortForward implements all the necessary functionality for port-forward cmd. +func (o PortForwardOptions) RunPortForward() error { + pod, err := o.PodClient.Pods(o.Namespace).Get(o.PodName, metav1.GetOptions{}) + if err != nil { + return err + } + + if pod.Status.Phase != corev1.PodRunning { + return fmt.Errorf("unable to forward port because pod is not running. Current status=%v", pod.Status.Phase) + } + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + defer signal.Stop(signals) + + go func() { + <-signals + if o.StopChannel != nil { + close(o.StopChannel) + } + }() + + req := o.RESTClient.Post(). + Resource("pods"). + Namespace(o.Namespace). + Name(pod.Name). + SubResource("portforward") + + return o.PortForwarder.ForwardPorts("POST", req.URL(), o) +} diff --git a/pkg/cmd/portforward/portforward_test.go b/pkg/cmd/portforward/portforward_test.go new file mode 100644 index 000000000..a491d47aa --- /dev/null +++ b/pkg/cmd/portforward/portforward_test.go @@ -0,0 +1,781 @@ +/* +Copyright 2014 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 portforward + +import ( + "fmt" + "net/http" + "net/url" + "reflect" + "testing" + + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +type fakePortForwarder struct { + method string + url *url.URL + pfErr error +} + +func (f *fakePortForwarder) ForwardPorts(method string, url *url.URL, opts PortForwardOptions) error { + f.method = method + f.url = url + return f.pfErr +} + +func testPortForward(t *testing.T, flags map[string]string, args []string) { + version := "v1" + + tests := []struct { + name string + podPath, pfPath string + pod *corev1.Pod + pfErr bool + }{ + { + name: "pod portforward", + podPath: "/api/" + version + "/namespaces/test/pods/foo", + pfPath: "/api/" + version + "/namespaces/test/pods/foo/portforward", + pod: execPod(), + }, + { + name: "pod portforward error", + podPath: "/api/" + version + "/namespaces/test/pods/foo", + pfPath: "/api/" + version + "/namespaces/test/pods/foo/portforward", + pod: execPod(), + pfErr: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var err error + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + VersionedAPIPath: "/api/v1", + GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == test.podPath && m == "GET": + body := cmdtesting.ObjBody(codec, test.pod) + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Errorf("%s: unexpected request: %#v\n%#v", test.name, req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + ff := &fakePortForwarder{} + if test.pfErr { + ff.pfErr = fmt.Errorf("pf error") + } + + opts := &PortForwardOptions{} + cmd := NewCmdPortForward(tf, genericclioptions.NewTestIOStreamsDiscard()) + cmd.Run = func(cmd *cobra.Command, args []string) { + if err = opts.Complete(tf, cmd, args); err != nil { + return + } + opts.PortForwarder = ff + if err = opts.Validate(); err != nil { + return + } + err = opts.RunPortForward() + } + + for name, value := range flags { + cmd.Flags().Set(name, value) + } + cmd.Run(cmd, args) + + if test.pfErr && err != ff.pfErr { + t.Errorf("%s: Unexpected port-forward error: %v", test.name, err) + } + if !test.pfErr && err != nil { + t.Errorf("%s: Unexpected error: %v", test.name, err) + } + if test.pfErr { + return + } + + if ff.url == nil || ff.url.Path != test.pfPath { + t.Errorf("%s: Did not get expected path for portforward request", test.name) + } + if ff.method != "POST" { + t.Errorf("%s: Did not get method for attach request: %s", test.name, ff.method) + } + }) + } +} + +func TestPortForward(t *testing.T) { + testPortForward(t, nil, []string{"foo", ":5000", ":1000"}) +} + +func TestTranslateServicePortToTargetPort(t *testing.T) { + cases := []struct { + name string + svc corev1.Service + pod corev1.Pod + ports []string + translated []string + err bool + }{ + { + name: "test success 1 (int port)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(8080)}, + }, + }, + }, + }, + }, + ports: []string{"80"}, + translated: []string{"80:8080"}, + err: false, + }, + { + name: "test success 1 (int port with random local port)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(8080)}, + }, + }, + }, + }, + }, + ports: []string{":80"}, + translated: []string{":8080"}, + err: false, + }, + { + name: "test success 2 (clusterIP: None)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + ClusterIP: "None", + Ports: []corev1.ServicePort{ + { + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(8080)}, + }, + }, + }, + }, + }, + ports: []string{"80"}, + translated: []string{"80"}, + err: false, + }, + { + name: "test success 2 (clusterIP: None with random local port)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + ClusterIP: "None", + Ports: []corev1.ServicePort{ + { + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(8080)}, + }, + }, + }, + }, + }, + ports: []string{":80"}, + translated: []string{":80"}, + err: false, + }, + { + name: "test success 3 (named target port)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + TargetPort: intstr.FromString("http"), + }, + { + Port: 443, + TargetPort: intstr.FromString("https"), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(8080)}, + { + Name: "https", + ContainerPort: int32(8443)}, + }, + }, + }, + }, + }, + ports: []string{"80", "443"}, + translated: []string{"80:8080", "443:8443"}, + err: false, + }, + { + name: "test success 3 (named target port with random local port)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + TargetPort: intstr.FromString("http"), + }, + { + Port: 443, + TargetPort: intstr.FromString("https"), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(8080)}, + { + Name: "https", + ContainerPort: int32(8443)}, + }, + }, + }, + }, + }, + ports: []string{":80", ":443"}, + translated: []string{":8080", ":8443"}, + err: false, + }, + { + name: "test success 4 (named service port)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + Name: "http", + TargetPort: intstr.FromInt(8080), + }, + { + Port: 443, + Name: "https", + TargetPort: intstr.FromInt(8443), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + ContainerPort: int32(8080)}, + { + ContainerPort: int32(8443)}, + }, + }, + }, + }, + }, + ports: []string{"http", "https"}, + translated: []string{"80:8080", "443:8443"}, + err: false, + }, + { + name: "test success 4 (named service port with random local port)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + Name: "http", + TargetPort: intstr.FromInt(8080), + }, + { + Port: 443, + Name: "https", + TargetPort: intstr.FromInt(8443), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + ContainerPort: int32(8080)}, + { + ContainerPort: int32(8443)}, + }, + }, + }, + }, + }, + ports: []string{":http", ":https"}, + translated: []string{":8080", ":8443"}, + err: false, + }, + { + name: "test success (targetPort omitted)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(80)}, + }, + }, + }, + }, + }, + ports: []string{"80"}, + translated: []string{"80"}, + err: false, + }, + { + name: "test success (targetPort omitted with random local port)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(80)}, + }, + }, + }, + }, + }, + ports: []string{":80"}, + translated: []string{":80"}, + err: false, + }, + { + name: "test failure 1 (named target port lookup failure)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + TargetPort: intstr.FromString("http"), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "https", + ContainerPort: int32(443)}, + }, + }, + }, + }, + }, + ports: []string{"80"}, + translated: []string{}, + err: true, + }, + { + name: "test failure 1 (named service port lookup failure)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + TargetPort: intstr.FromString("http"), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(8080)}, + }, + }, + }, + }, + }, + ports: []string{"https"}, + translated: []string{}, + err: true, + }, + { + name: "test failure 2 (service port not declared)", + svc: corev1.Service{ + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + TargetPort: intstr.FromString("http"), + }, + }, + }, + }, + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "https", + ContainerPort: int32(443)}, + }, + }, + }, + }, + }, + ports: []string{"443"}, + translated: []string{}, + err: true, + }, + } + + for _, tc := range cases { + translated, err := translateServicePortToTargetPort(tc.ports, tc.svc, tc.pod) + if err != nil { + if tc.err { + continue + } + + t.Errorf("%v: unexpected error: %v", tc.name, err) + continue + } + + if tc.err { + t.Errorf("%v: unexpected success", tc.name) + continue + } + + if !reflect.DeepEqual(translated, tc.translated) { + t.Errorf("%v: expected %v; got %v", tc.name, tc.translated, translated) + } + } +} + +func execPod() *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "test", ResourceVersion: "10"}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + DNSPolicy: corev1.DNSClusterFirst, + Containers: []corev1.Container{ + { + Name: "bar", + }, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } +} + +func TestConvertPodNamedPortToNumber(t *testing.T) { + cases := []struct { + name string + pod corev1.Pod + ports []string + converted []string + err bool + }{ + { + name: "port number without local port", + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(80)}, + }, + }, + }, + }, + }, + ports: []string{"80"}, + converted: []string{"80"}, + err: false, + }, + { + name: "port number with local port", + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(80)}, + }, + }, + }, + }, + }, + ports: []string{"8000:80"}, + converted: []string{"8000:80"}, + err: false, + }, + { + name: "port number with random local port", + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(80)}, + }, + }, + }, + }, + }, + ports: []string{":80"}, + converted: []string{":80"}, + err: false, + }, + { + name: "named port without local port", + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(80)}, + }, + }, + }, + }, + }, + ports: []string{"http"}, + converted: []string{"80"}, + err: false, + }, + { + name: "named port with local port", + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(80)}, + }, + }, + }, + }, + }, + ports: []string{"8000:http"}, + converted: []string{"8000:80"}, + err: false, + }, + { + name: "named port with random local port", + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "http", + ContainerPort: int32(80)}, + }, + }, + }, + }, + }, + ports: []string{":http"}, + converted: []string{":80"}, + err: false, + }, + { + name: "named port can not be found", + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "https", + ContainerPort: int32(443)}, + }, + }, + }, + }, + }, + ports: []string{"http"}, + err: true, + }, + { + name: "one of the requested named ports can not be found", + pod: corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Ports: []corev1.ContainerPort{ + { + Name: "https", + ContainerPort: int32(443)}, + }, + }, + }, + }, + }, + ports: []string{"https", "http"}, + err: true, + }, + } + + for _, tc := range cases { + converted, err := convertPodNamedPortToNumber(tc.ports, tc.pod) + if err != nil { + if tc.err { + continue + } + + t.Errorf("%v: unexpected error: %v", tc.name, err) + continue + } + + if tc.err { + t.Errorf("%v: unexpected success", tc.name) + continue + } + + if !reflect.DeepEqual(converted, tc.converted) { + t.Errorf("%v: expected %v; got %v", tc.name, tc.converted, converted) + } + } +} diff --git a/pkg/cmd/profiling.go b/pkg/cmd/profiling.go new file mode 100644 index 000000000..2a1c1ce3c --- /dev/null +++ b/pkg/cmd/profiling.go @@ -0,0 +1,88 @@ +/* +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 cmd + +import ( + "fmt" + "os" + "runtime" + "runtime/pprof" + + "github.com/spf13/pflag" +) + +var ( + profileName string + profileOutput string +) + +func addProfilingFlags(flags *pflag.FlagSet) { + flags.StringVar(&profileName, "profile", "none", "Name of profile to capture. One of (none|cpu|heap|goroutine|threadcreate|block|mutex)") + flags.StringVar(&profileOutput, "profile-output", "profile.pprof", "Name of the file to write the profile to") +} + +func initProfiling() error { + switch profileName { + case "none": + return nil + case "cpu": + f, err := os.Create(profileOutput) + if err != nil { + return err + } + return pprof.StartCPUProfile(f) + // Block and mutex profiles need a call to Set{Block,Mutex}ProfileRate to + // output anything. We choose to sample all events. + case "block": + runtime.SetBlockProfileRate(1) + return nil + case "mutex": + runtime.SetMutexProfileFraction(1) + return nil + default: + // Check the profile name is valid. + if profile := pprof.Lookup(profileName); profile == nil { + return fmt.Errorf("unknown profile '%s'", profileName) + } + } + + return nil +} + +func flushProfiling() error { + switch profileName { + case "none": + return nil + case "cpu": + pprof.StopCPUProfile() + case "heap": + runtime.GC() + fallthrough + default: + profile := pprof.Lookup(profileName) + if profile == nil { + return nil + } + f, err := os.Create(profileOutput) + if err != nil { + return err + } + profile.WriteTo(f, 0) + } + + return nil +} diff --git a/pkg/cmd/proxy/proxy.go b/pkg/cmd/proxy/proxy.go new file mode 100644 index 000000000..1867b02b5 --- /dev/null +++ b/pkg/cmd/proxy/proxy.go @@ -0,0 +1,165 @@ +/* +Copyright 2014 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 proxy + +import ( + "errors" + "fmt" + "io" + "net" + "os" + "strings" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/klog" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/proxy" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + defaultPort = 8001 + proxyLong = templates.LongDesc(i18n.T(` + Creates a proxy server or application-level gateway between localhost and + the Kubernetes API Server. It also allows serving static content over specified + HTTP path. All incoming data enters through one port and gets forwarded to + the remote kubernetes API Server port, except for the path matching the static content path.`)) + + proxyExample = templates.Examples(i18n.T(` + # To proxy all of the kubernetes api and nothing else, use: + + $ kubectl proxy --api-prefix=/ + + # To proxy only part of the kubernetes api and also some static files: + + $ kubectl proxy --www=/my/files --www-prefix=/static/ --api-prefix=/api/ + + # The above lets you 'curl localhost:8001/api/v1/pods'. + + # To proxy the entire kubernetes api at a different root, use: + + $ kubectl proxy --api-prefix=/custom/ + + # The above lets you 'curl localhost:8001/custom/api/v1/pods' + + # Run a proxy to kubernetes apiserver on port 8011, serving static content from ./local/www/ + kubectl proxy --port=8011 --www=./local/www/ + + # Run a proxy to kubernetes apiserver on an arbitrary local port. + # The chosen port for the server will be output to stdout. + kubectl proxy --port=0 + + # Run a proxy to kubernetes apiserver, changing the api prefix to k8s-api + # This makes e.g. the pods api available at localhost:8001/k8s-api/v1/pods/ + kubectl proxy --api-prefix=/k8s-api`)) +) + +// NewCmdProxy returns the proxy Cobra command +func NewCmdProxy(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "proxy [--port=PORT] [--www=static-dir] [--www-prefix=prefix] [--api-prefix=prefix]", + DisableFlagsInUseLine: true, + Short: i18n.T("Run a proxy to the Kubernetes API server"), + Long: proxyLong, + Example: proxyExample, + Run: func(cmd *cobra.Command, args []string) { + err := RunProxy(f, streams.Out, cmd) + cmdutil.CheckErr(err) + }, + } + cmd.Flags().StringP("www", "w", "", "Also serve static files from the given directory under the specified prefix.") + cmd.Flags().StringP("www-prefix", "P", "/static/", "Prefix to serve static files under, if static file directory is specified.") + cmd.Flags().StringP("api-prefix", "", "/", "Prefix to serve the proxied API under.") + cmd.Flags().String("accept-paths", proxy.DefaultPathAcceptRE, "Regular expression for paths that the proxy should accept.") + cmd.Flags().String("reject-paths", proxy.DefaultPathRejectRE, "Regular expression for paths that the proxy should reject. Paths specified here will be rejected even accepted by --accept-paths.") + cmd.Flags().String("accept-hosts", proxy.DefaultHostAcceptRE, "Regular expression for hosts that the proxy should accept.") + cmd.Flags().String("reject-methods", proxy.DefaultMethodRejectRE, "Regular expression for HTTP methods that the proxy should reject (example --reject-methods='POST,PUT,PATCH'). ") + cmd.Flags().IntP("port", "p", defaultPort, "The port on which to run the proxy. Set to 0 to pick a random port.") + cmd.Flags().StringP("address", "", "127.0.0.1", "The IP address on which to serve on.") + cmd.Flags().Bool("disable-filter", false, "If true, disable request filtering in the proxy. This is dangerous, and can leave you vulnerable to XSRF attacks, when used with an accessible port.") + cmd.Flags().StringP("unix-socket", "u", "", "Unix socket on which to run the proxy.") + cmd.Flags().Duration("keepalive", 0, "keepalive specifies the keep-alive period for an active network connection. Set to 0 to disable keepalive.") + return cmd +} + +// RunProxy checks given arguments and executes command +func RunProxy(f cmdutil.Factory, out io.Writer, cmd *cobra.Command) error { + path := cmdutil.GetFlagString(cmd, "unix-socket") + port := cmdutil.GetFlagInt(cmd, "port") + address := cmdutil.GetFlagString(cmd, "address") + + if port != defaultPort && path != "" { + return errors.New("Don't specify both --unix-socket and --port") + } + + clientConfig, err := f.ToRESTConfig() + if err != nil { + return err + } + + staticPrefix := cmdutil.GetFlagString(cmd, "www-prefix") + if !strings.HasSuffix(staticPrefix, "/") { + staticPrefix += "/" + } + staticDir := cmdutil.GetFlagString(cmd, "www") + if staticDir != "" { + fileInfo, err := os.Stat(staticDir) + if err != nil { + klog.Warning("Failed to stat static file directory "+staticDir+": ", err) + } else if !fileInfo.IsDir() { + klog.Warning("Static file directory " + staticDir + " is not a directory") + } + } + + apiProxyPrefix := cmdutil.GetFlagString(cmd, "api-prefix") + if !strings.HasSuffix(apiProxyPrefix, "/") { + apiProxyPrefix += "/" + } + filter := &proxy.FilterServer{ + AcceptPaths: proxy.MakeRegexpArrayOrDie(cmdutil.GetFlagString(cmd, "accept-paths")), + RejectPaths: proxy.MakeRegexpArrayOrDie(cmdutil.GetFlagString(cmd, "reject-paths")), + AcceptHosts: proxy.MakeRegexpArrayOrDie(cmdutil.GetFlagString(cmd, "accept-hosts")), + RejectMethods: proxy.MakeRegexpArrayOrDie(cmdutil.GetFlagString(cmd, "reject-methods")), + } + if cmdutil.GetFlagBool(cmd, "disable-filter") { + if path == "" { + klog.Warning("Request filter disabled, your proxy is vulnerable to XSRF attacks, please be cautious") + } + filter = nil + } + + keepalive := cmdutil.GetFlagDuration(cmd, "keepalive") + + server, err := proxy.NewServer(staticDir, apiProxyPrefix, staticPrefix, filter, clientConfig, keepalive) + + // Separate listening from serving so we can report the bound port + // when it is chosen by os (eg: port == 0) + var l net.Listener + if path == "" { + l, err = server.Listen(address, port) + } else { + l, err = server.ListenUnix(path) + } + if err != nil { + klog.Fatal(err) + } + fmt.Fprintf(out, "Starting to serve on %s\n", l.Addr().String()) + klog.Fatal(server.ServeOnListener(l)) + return nil +} diff --git a/pkg/cmd/replace/replace.go b/pkg/cmd/replace/replace.go new file mode 100644 index 000000000..0c01824f8 --- /dev/null +++ b/pkg/cmd/replace/replace.go @@ -0,0 +1,374 @@ +/* +Copyright 2014 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 replace + +import ( + "fmt" + "io/ioutil" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "k8s.io/klog" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/rawhttp" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubectl/pkg/validation" + "k8s.io/kubernetes/pkg/kubectl/cmd/delete" +) + +var ( + replaceLong = templates.LongDesc(i18n.T(` + Replace a resource by filename or stdin. + + JSON and YAML formats are accepted. If replacing an existing resource, the + complete resource spec must be provided. This can be obtained by + + $ kubectl get TYPE NAME -o yaml`)) + + replaceExample = templates.Examples(i18n.T(` + # Replace a pod using the data in pod.json. + kubectl replace -f ./pod.json + + # Replace a pod based on the JSON passed into stdin. + cat pod.json | kubectl replace -f - + + # Update a single-container pod's image version (tag) to v4 + kubectl get pod mypod -o yaml | sed 's/\(image: myimage\):.*$/\1:v4/' | kubectl replace -f - + + # Force replace, delete and then re-create the resource + kubectl replace --force -f ./pod.json`)) +) + +type ReplaceOptions struct { + PrintFlags *genericclioptions.PrintFlags + RecordFlags *genericclioptions.RecordFlags + + DeleteFlags *delete.DeleteFlags + DeleteOptions *delete.DeleteOptions + + PrintObj func(obj runtime.Object) error + + createAnnotation bool + validate bool + + Schema validation.Schema + Builder func() *resource.Builder + BuilderArgs []string + + Namespace string + EnforceNamespace bool + Raw string + + Recorder genericclioptions.Recorder + + genericclioptions.IOStreams +} + +func NewReplaceOptions(streams genericclioptions.IOStreams) *ReplaceOptions { + return &ReplaceOptions{ + PrintFlags: genericclioptions.NewPrintFlags("replaced"), + DeleteFlags: delete.NewDeleteFlags("to use to replace the resource."), + + IOStreams: streams, + } +} + +func NewCmdReplace(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewReplaceOptions(streams) + + cmd := &cobra.Command{ + Use: "replace -f FILENAME", + DisableFlagsInUseLine: true, + Short: i18n.T("Replace a resource by filename or stdin"), + Long: replaceLong, + Example: replaceExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate(cmd)) + cmdutil.CheckErr(o.Run(f)) + }, + } + + o.PrintFlags.AddFlags(cmd) + o.DeleteFlags.AddFlags(cmd) + o.RecordFlags.AddFlags(cmd) + + cmdutil.AddValidateFlags(cmd) + cmdutil.AddApplyAnnotationFlags(cmd) + + cmd.Flags().StringVar(&o.Raw, "raw", o.Raw, "Raw URI to PUT to the server. Uses the transport specified by the kubeconfig file.") + + return cmd +} + +func (o *ReplaceOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.validate = cmdutil.GetFlagBool(cmd, "validate") + o.createAnnotation = cmdutil.GetFlagBool(cmd, cmdutil.ApplyAnnotationsFlag) + + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = func(obj runtime.Object) error { + return printer.PrintObj(obj, o.Out) + } + + dynamicClient, err := f.DynamicClient() + if err != nil { + return err + } + deleteOpts := o.DeleteFlags.ToOptions(dynamicClient, o.IOStreams) + + //Replace will create a resource if it doesn't exist already, so ignore not found error + deleteOpts.IgnoreNotFound = true + if o.PrintFlags.OutputFormat != nil { + deleteOpts.Output = *o.PrintFlags.OutputFormat + } + if deleteOpts.GracePeriod == 0 { + // To preserve backwards compatibility, but prevent accidental data loss, we convert --grace-period=0 + // into --grace-period=1 and wait until the object is successfully deleted. + deleteOpts.GracePeriod = 1 + deleteOpts.WaitForDeletion = true + } + o.DeleteOptions = deleteOpts + + err = o.DeleteOptions.FilenameOptions.RequireFilenameOrKustomize() + if err != nil { + return err + } + + schema, err := f.Validator(o.validate) + if err != nil { + return err + } + + o.Schema = schema + o.Builder = f.NewBuilder + o.BuilderArgs = args + + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + return nil +} + +func (o *ReplaceOptions) Validate(cmd *cobra.Command) error { + if o.DeleteOptions.GracePeriod >= 0 && !o.DeleteOptions.ForceDeletion { + return fmt.Errorf("--grace-period must have --force specified") + } + + if o.DeleteOptions.Timeout != 0 && !o.DeleteOptions.ForceDeletion { + return fmt.Errorf("--timeout must have --force specified") + } + + if cmdutil.IsFilenameSliceEmpty(o.DeleteOptions.FilenameOptions.Filenames, o.DeleteOptions.FilenameOptions.Kustomize) { + return cmdutil.UsageErrorf(cmd, "Must specify --filename to replace") + } + + if len(o.Raw) > 0 { + if len(o.DeleteOptions.FilenameOptions.Filenames) != 1 { + return cmdutil.UsageErrorf(cmd, "--raw can only use a single local file or stdin") + } + if strings.Index(o.DeleteOptions.FilenameOptions.Filenames[0], "http://") == 0 || strings.Index(o.DeleteOptions.FilenameOptions.Filenames[0], "https://") == 0 { + return cmdutil.UsageErrorf(cmd, "--raw cannot read from a url") + } + if o.DeleteOptions.FilenameOptions.Recursive { + return cmdutil.UsageErrorf(cmd, "--raw and --recursive are mutually exclusive") + } + if len(cmdutil.GetFlagString(cmd, "output")) > 0 { + return cmdutil.UsageErrorf(cmd, "--raw and --output are mutually exclusive") + } + if _, err := url.ParseRequestURI(o.Raw); err != nil { + return cmdutil.UsageErrorf(cmd, "--raw must be a valid URL path: %v", err) + } + } + + return nil +} + +func (o *ReplaceOptions) Run(f cmdutil.Factory) error { + // raw only makes sense for a single file resource multiple objects aren't likely to do what you want. + // the validator enforces this, so + if len(o.Raw) > 0 { + restClient, err := f.RESTClient() + if err != nil { + return err + } + return rawhttp.RawPut(restClient, o.IOStreams, o.Raw, o.DeleteOptions.Filenames[0]) + } + + if o.DeleteOptions.ForceDeletion { + return o.forceReplace() + } + + r := o.Builder(). + Unstructured(). + Schema(o.Schema). + ContinueOnError(). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.DeleteOptions.FilenameOptions). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + return r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + if err := util.CreateOrUpdateAnnotation(o.createAnnotation, info.Object, scheme.DefaultJSONEncoder()); err != nil { + return cmdutil.AddSourceToErr("replacing", info.Source, err) + } + + if err := o.Recorder.Record(info.Object); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + // Serialize the object with the annotation applied. + obj, err := resource.NewHelper(info.Client, info.Mapping).Replace(info.Namespace, info.Name, true, info.Object) + if err != nil { + return cmdutil.AddSourceToErr("replacing", info.Source, err) + } + + info.Refresh(obj, true) + return o.PrintObj(info.Object) + }) +} + +func (o *ReplaceOptions) forceReplace() error { + for i, filename := range o.DeleteOptions.FilenameOptions.Filenames { + if filename == "-" { + tempDir, err := ioutil.TempDir("", "kubectl_replace_") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + tempFilename := filepath.Join(tempDir, "resource.stdin") + err = cmdutil.DumpReaderToFile(os.Stdin, tempFilename) + if err != nil { + return err + } + o.DeleteOptions.FilenameOptions.Filenames[i] = tempFilename + } + } + + r := o.Builder(). + Unstructured(). + ContinueOnError(). + NamespaceParam(o.Namespace).DefaultNamespace(). + ResourceTypeOrNameArgs(false, o.BuilderArgs...).RequireObject(false). + FilenameParam(o.EnforceNamespace, &o.DeleteOptions.FilenameOptions). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + if err := o.DeleteOptions.DeleteResult(r); err != nil { + return err + } + + timeout := o.DeleteOptions.Timeout + if timeout == 0 { + timeout = 5 * time.Minute + } + err := r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + return wait.PollImmediate(1*time.Second, timeout, func() (bool, error) { + if err := info.Get(); !errors.IsNotFound(err) { + return false, err + } + return true, nil + }) + }) + if err != nil { + return err + } + + r = o.Builder(). + Unstructured(). + Schema(o.Schema). + ContinueOnError(). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.DeleteOptions.FilenameOptions). + Flatten(). + Do() + err = r.Err() + if err != nil { + return err + } + + count := 0 + err = r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + if err := util.CreateOrUpdateAnnotation(o.createAnnotation, info.Object, scheme.DefaultJSONEncoder()); err != nil { + return err + } + + if err := o.Recorder.Record(info.Object); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + obj, err := resource.NewHelper(info.Client, info.Mapping).Create(info.Namespace, true, info.Object, nil) + if err != nil { + return err + } + + count++ + info.Refresh(obj, true) + return o.PrintObj(info.Object) + }) + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("no objects passed to replace") + } + return nil +} diff --git a/pkg/cmd/replace/replace_test.go b/pkg/cmd/replace/replace_test.go new file mode 100644 index 000000000..342fcd18a --- /dev/null +++ b/pkg/cmd/replace/replace_test.go @@ -0,0 +1,250 @@ +/* +Copyright 2015 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 replace + +import ( + "net/http" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestReplaceObject(t *testing.T) { + _, _, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + deleted := false + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api/v1/namespaces/test" && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.Namespace{})}, nil + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == http.MethodDelete: + deleted = true + fallthrough + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == http.MethodPut: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == http.MethodGet: + statusCode := http.StatusOK + if deleted { + statusCode = http.StatusNotFound + } + return &http.Response{StatusCode: statusCode, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case p == "/namespaces/test/replicationcontrollers" && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdReplace(tf, streams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + // uses the name from the file, not the response + if buf.String() != "replicationcontroller/rc1\n" { + t.Errorf("unexpected output: %s", buf.String()) + } + + buf.Reset() + cmd.Flags().Set("force", "true") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if buf.String() != "replicationcontroller/redis-master\nreplicationcontroller/rc1\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestReplaceMultipleObject(t *testing.T) { + _, svc, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + redisMasterDeleted := false + frontendDeleted := false + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api/v1/namespaces/test" && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.Namespace{})}, nil + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == http.MethodDelete: + redisMasterDeleted = true + fallthrough + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == http.MethodPut: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case p == "/namespaces/test/replicationcontrollers/redis-master" && m == http.MethodGet: + statusCode := http.StatusOK + if redisMasterDeleted { + statusCode = http.StatusNotFound + } + return &http.Response{StatusCode: statusCode, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case p == "/namespaces/test/replicationcontrollers" && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case p == "/namespaces/test/services/frontend" && m == http.MethodDelete: + frontendDeleted = true + fallthrough + case p == "/namespaces/test/services/frontend" && m == http.MethodPut: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + case p == "/namespaces/test/services/frontend" && m == http.MethodGet: + statusCode := http.StatusOK + if frontendDeleted { + statusCode = http.StatusNotFound + } + return &http.Response{StatusCode: statusCode, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + case p == "/namespaces/test/services" && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &svc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdReplace(tf, streams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml") + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/frontend-service.yaml") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if buf.String() != "replicationcontroller/rc1\nservice/baz\n" { + t.Errorf("unexpected output: %s", buf.String()) + } + + buf.Reset() + cmd.Flags().Set("force", "true") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if buf.String() != "replicationcontroller/redis-master\nservice/frontend\nreplicationcontroller/rc1\nservice/baz\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestReplaceDirectory(t *testing.T) { + _, _, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + created := map[string]bool{} + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api/v1/namespaces/test" && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.Namespace{})}, nil + case strings.HasPrefix(p, "/namespaces/test/replicationcontrollers/") && m == http.MethodPut: + created[p] = true + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case strings.HasPrefix(p, "/namespaces/test/replicationcontrollers/") && m == http.MethodGet: + statusCode := http.StatusNotFound + if created[p] { + statusCode = http.StatusOK + } + return &http.Response{StatusCode: statusCode, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case strings.HasPrefix(p, "/namespaces/test/replicationcontrollers/") && m == http.MethodDelete: + delete(created, p) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + case strings.HasPrefix(p, "/namespaces/test/replicationcontrollers") && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdReplace(tf, streams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy") + cmd.Flags().Set("namespace", "test") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if buf.String() != "replicationcontroller/rc1\nreplicationcontroller/rc1\nreplicationcontroller/rc1\n" { + t.Errorf("unexpected output: %s", buf.String()) + } + + buf.Reset() + cmd.Flags().Set("force", "true") + cmd.Flags().Set("cascade", "false") + cmd.Run(cmd, []string{}) + + if buf.String() != "replicationcontroller/frontend\nreplicationcontroller/redis-master\nreplicationcontroller/redis-slave\n"+ + "replicationcontroller/rc1\nreplicationcontroller/rc1\nreplicationcontroller/rc1\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestForceReplaceObjectNotFound(t *testing.T) { + _, _, rc := cmdtesting.TestData() + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + tf.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api/v1/namespaces/test" && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &corev1.Namespace{})}, nil + case p == "/namespaces/test/replicationcontrollers/redis-master" && (m == http.MethodGet || m == http.MethodDelete): + return &http.Response{StatusCode: http.StatusNotFound, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.StringBody("")}, nil + case p == "/namespaces/test/replicationcontrollers" && m == http.MethodPost: + return &http.Response{StatusCode: http.StatusCreated, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &rc.Items[0])}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdReplace(tf, streams) + cmd.Flags().Set("filename", "../../../../test/e2e/testing-manifests/guestbook/legacy/redis-master-controller.yaml") + cmd.Flags().Set("force", "true") + cmd.Flags().Set("cascade", "false") + cmd.Flags().Set("output", "name") + cmd.Run(cmd, []string{}) + + if buf.String() != "replicationcontroller/rc1\n" { + t.Errorf("unexpected output: %s", buf.String()) + } +} diff --git a/pkg/cmd/rollingupdate/rolling_updater.go b/pkg/cmd/rollingupdate/rolling_updater.go new file mode 100644 index 000000000..2fed4128e --- /dev/null +++ b/pkg/cmd/rollingupdate/rolling_updater.go @@ -0,0 +1,865 @@ +/* +Copyright 2014 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 rollingupdate + +import ( + "fmt" + "io" + "strconv" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/wait" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + scaleclient "k8s.io/client-go/scale" + "k8s.io/client-go/util/retry" + "k8s.io/kubectl/pkg/scale" + "k8s.io/kubectl/pkg/util" + deploymentutil "k8s.io/kubectl/pkg/util/deployment" + "k8s.io/kubectl/pkg/util/podutils" + "k8s.io/utils/integer" + utilpointer "k8s.io/utils/pointer" +) + +func valOrZero(val *int32) int32 { + if val == nil { + return int32(0) + } + return *val +} + +const ( + kubectlAnnotationPrefix = "kubectl.kubernetes.io/" + sourceIDAnnotation = kubectlAnnotationPrefix + "update-source-id" + desiredReplicasAnnotation = kubectlAnnotationPrefix + "desired-replicas" + originalReplicasAnnotation = kubectlAnnotationPrefix + "original-replicas" + nextControllerAnnotation = kubectlAnnotationPrefix + "next-controller-id" +) + +// RollingUpdaterConfig is the configuration for a rolling deployment process. +type RollingUpdaterConfig struct { + // Out is a writer for progress output. + Out io.Writer + // OldRC is an existing controller to be replaced. + OldRc *corev1.ReplicationController + // NewRc is a controller that will take ownership of updated pods (will be + // created if needed). + NewRc *corev1.ReplicationController + // UpdatePeriod is the time to wait between individual pod updates. + UpdatePeriod time.Duration + // Interval is the time to wait between polling controller status after + // update. + Interval time.Duration + // Timeout is the time to wait for controller updates before giving up. + Timeout time.Duration + // MinReadySeconds is the number of seconds to wait after the pods are ready + MinReadySeconds int32 + // CleanupPolicy defines the cleanup action to take after the deployment is + // complete. + CleanupPolicy RollingUpdaterCleanupPolicy + // MaxUnavailable is the maximum number of pods that can be unavailable during the update. + // Value can be an absolute number (ex: 5) or a percentage of desired pods (ex: 10%). + // Absolute number is calculated from percentage by rounding up. + // This can not be 0 if MaxSurge is 0. + // By default, a fixed value of 1 is used. + // Example: when this is set to 30%, the old RC can be scaled down to 70% of desired pods + // immediately when the rolling update starts. Once new pods are ready, old RC + // can be scaled down further, followed by scaling up the new RC, ensuring + // that the total number of pods available at all times during the update is at + // least 70% of desired pods. + MaxUnavailable intstr.IntOrString + // MaxSurge is the maximum number of pods that can be scheduled above the desired number of pods. + // Value can be an absolute number (ex: 5) or a percentage of desired pods (ex: 10%). + // This can not be 0 if MaxUnavailable is 0. + // Absolute number is calculated from percentage by rounding up. + // By default, a value of 1 is used. + // Example: when this is set to 30%, the new RC can be scaled up immediately + // when the rolling update starts, such that the total number of old and new pods do not exceed + // 130% of desired pods. Once old pods have been killed, new RC can be scaled up + // further, ensuring that total number of pods running at any time during + // the update is at most 130% of desired pods. + MaxSurge intstr.IntOrString + // OnProgress is invoked if set during each scale cycle, to allow the caller to perform additional logic or + // abort the scale. If an error is returned the cleanup method will not be invoked. The percentage value + // is a synthetic "progress" calculation that represents the approximate percentage completion. + OnProgress func(oldRc, newRc *corev1.ReplicationController, percentage int) error +} + +// RollingUpdaterCleanupPolicy is a cleanup action to take after the +// deployment is complete. +type RollingUpdaterCleanupPolicy string + +const ( + // DeleteRollingUpdateCleanupPolicy means delete the old controller. + DeleteRollingUpdateCleanupPolicy RollingUpdaterCleanupPolicy = "Delete" + // PreserveRollingUpdateCleanupPolicy means keep the old controller. + PreserveRollingUpdateCleanupPolicy RollingUpdaterCleanupPolicy = "Preserve" + // RenameRollingUpdateCleanupPolicy means delete the old controller, and rename + // the new controller to the name of the old controller. + RenameRollingUpdateCleanupPolicy RollingUpdaterCleanupPolicy = "Rename" +) + +// RollingUpdater provides methods for updating replicated pods in a predictable, +// fault-tolerant way. +type RollingUpdater struct { + rcClient corev1client.ReplicationControllersGetter + podClient corev1client.PodsGetter + scaleClient scaleclient.ScalesGetter + // Namespace for resources + ns string + // scaleAndWait scales a controller and returns its updated state. + scaleAndWait func(rc *corev1.ReplicationController, retry *scale.RetryParams, wait *scale.RetryParams) (*corev1.ReplicationController, error) + //getOrCreateTargetController gets and validates an existing controller or + //makes a new one. + getOrCreateTargetController func(controller *corev1.ReplicationController, sourceID string) (*corev1.ReplicationController, bool, error) + // cleanup performs post deployment cleanup tasks for newRc and oldRc. + cleanup func(oldRc, newRc *corev1.ReplicationController, config *RollingUpdaterConfig) error + // getReadyPods returns the amount of old and new ready pods. + getReadyPods func(oldRc, newRc *corev1.ReplicationController, minReadySeconds int32) (int32, int32, error) + // nowFn returns the current time used to calculate the minReadySeconds + nowFn func() metav1.Time +} + +// NewRollingUpdater creates a RollingUpdater from a client. +func NewRollingUpdater(namespace string, rcClient corev1client.ReplicationControllersGetter, podClient corev1client.PodsGetter, sc scaleclient.ScalesGetter) *RollingUpdater { + updater := &RollingUpdater{ + rcClient: rcClient, + podClient: podClient, + scaleClient: sc, + ns: namespace, + } + // Inject real implementations. + updater.scaleAndWait = updater.scaleAndWaitWithScaler + updater.getOrCreateTargetController = updater.getOrCreateTargetControllerWithClient + updater.getReadyPods = updater.readyPods + updater.cleanup = updater.cleanupWithClients + updater.nowFn = func() metav1.Time { return metav1.Now() } + return updater +} + +// Update all pods for a ReplicationController (oldRc) by creating a new +// controller (newRc) with 0 replicas, and synchronously scaling oldRc and +// newRc until oldRc has 0 replicas and newRc has the original # of desired +// replicas. Cleanup occurs based on a RollingUpdaterCleanupPolicy. +// +// Each interval, the updater will attempt to make progress however it can +// without violating any availability constraints defined by the config. This +// means the amount scaled up or down each interval will vary based on the +// timeliness of readiness and the updater will always try to make progress, +// even slowly. +// +// If an update from newRc to oldRc is already in progress, we attempt to +// drive it to completion. If an error occurs at any step of the update, the +// error will be returned. +// +// A scaling event (either up or down) is considered progress; if no progress +// is made within the config.Timeout, an error is returned. +// +// TODO: make this handle performing a rollback of a partially completed +// rollout. +func (r *RollingUpdater) Update(config *RollingUpdaterConfig) error { + out := config.Out + oldRc := config.OldRc + scaleRetryParams := scale.NewRetryParams(config.Interval, config.Timeout) + + // Find an existing controller (for continuing an interrupted update) or + // create a new one if necessary. + sourceID := fmt.Sprintf("%s:%s", oldRc.Name, oldRc.UID) + newRc, existed, err := r.getOrCreateTargetController(config.NewRc, sourceID) + if err != nil { + return err + } + if existed { + fmt.Fprintf(out, "Continuing update with existing controller %s.\n", newRc.Name) + } else { + fmt.Fprintf(out, "Created %s\n", newRc.Name) + } + // Extract the desired replica count from the controller. + desiredAnnotation, err := strconv.Atoi(newRc.Annotations[desiredReplicasAnnotation]) + if err != nil { + return fmt.Errorf("Unable to parse annotation for %s: %s=%s", + newRc.Name, desiredReplicasAnnotation, newRc.Annotations[desiredReplicasAnnotation]) + } + desired := int32(desiredAnnotation) + // Extract the original replica count from the old controller, adding the + // annotation if it doesn't yet exist. + _, hasOriginalAnnotation := oldRc.Annotations[originalReplicasAnnotation] + if !hasOriginalAnnotation { + existing, err := r.rcClient.ReplicationControllers(oldRc.Namespace).Get(oldRc.Name, metav1.GetOptions{}) + if err != nil { + return err + } + originReplicas := strconv.Itoa(int(valOrZero(existing.Spec.Replicas))) + applyUpdate := func(rc *corev1.ReplicationController) { + if rc.Annotations == nil { + rc.Annotations = map[string]string{} + } + rc.Annotations[originalReplicasAnnotation] = originReplicas + } + if oldRc, err = updateRcWithRetries(r.rcClient, existing.Namespace, existing, applyUpdate); err != nil { + return err + } + } + // maxSurge is the maximum scaling increment and maxUnavailable are the maximum pods + // that can be unavailable during a rollout. + maxSurge, maxUnavailable, err := deploymentutil.ResolveFenceposts(&config.MaxSurge, &config.MaxUnavailable, desired) + if err != nil { + return err + } + // Validate maximums. + if desired > 0 && maxUnavailable == 0 && maxSurge == 0 { + return fmt.Errorf("one of maxSurge or maxUnavailable must be specified") + } + // The minimum pods which must remain available throughout the update + // calculated for internal convenience. + minAvailable := int32(integer.IntMax(0, int(desired-maxUnavailable))) + // If the desired new scale is 0, then the max unavailable is necessarily + // the effective scale of the old RC regardless of the configuration + // (equivalent to 100% maxUnavailable). + if desired == 0 { + maxUnavailable = valOrZero(oldRc.Spec.Replicas) + minAvailable = 0 + } + + fmt.Fprintf(out, "Scaling up %s from %d to %d, scaling down %s from %d to 0 (keep %d pods available, don't exceed %d pods)\n", + newRc.Name, valOrZero(newRc.Spec.Replicas), desired, oldRc.Name, valOrZero(oldRc.Spec.Replicas), minAvailable, desired+maxSurge) + + // give a caller incremental notification and allow them to exit early + goal := desired - valOrZero(newRc.Spec.Replicas) + if goal < 0 { + goal = -goal + } + progress := func(complete bool) error { + if config.OnProgress == nil { + return nil + } + progress := desired - valOrZero(newRc.Spec.Replicas) + if progress < 0 { + progress = -progress + } + percentage := 100 + if !complete && goal > 0 { + percentage = int((goal - progress) * 100 / goal) + } + return config.OnProgress(oldRc, newRc, percentage) + } + + // Scale newRc and oldRc until newRc has the desired number of replicas and + // oldRc has 0 replicas. + progressDeadline := time.Now().UnixNano() + config.Timeout.Nanoseconds() + for valOrZero(newRc.Spec.Replicas) != desired || valOrZero(oldRc.Spec.Replicas) != 0 { + // Store the existing replica counts for progress timeout tracking. + newReplicas := valOrZero(newRc.Spec.Replicas) + oldReplicas := valOrZero(oldRc.Spec.Replicas) + + // Scale up as much as possible. + scaledRc, err := r.scaleUp(newRc, oldRc, desired, maxSurge, maxUnavailable, scaleRetryParams, config) + if err != nil { + return err + } + newRc = scaledRc + + // notify the caller if necessary + if err := progress(false); err != nil { + return err + } + + // Wait between scaling operations for things to settle. + time.Sleep(config.UpdatePeriod) + + // Scale down as much as possible. + scaledRc, err = r.scaleDown(newRc, oldRc, desired, minAvailable, maxUnavailable, maxSurge, config) + if err != nil { + return err + } + oldRc = scaledRc + + // notify the caller if necessary + if err := progress(false); err != nil { + return err + } + + // If we are making progress, continue to advance the progress deadline. + // Otherwise, time out with an error. + progressMade := (valOrZero(newRc.Spec.Replicas) != newReplicas) || (valOrZero(oldRc.Spec.Replicas) != oldReplicas) + if progressMade { + progressDeadline = time.Now().UnixNano() + config.Timeout.Nanoseconds() + } else if time.Now().UnixNano() > progressDeadline { + return fmt.Errorf("timed out waiting for any update progress to be made") + } + } + + // notify the caller if necessary + if err := progress(true); err != nil { + return err + } + + // Housekeeping and cleanup policy execution. + return r.cleanup(oldRc, newRc, config) +} + +// scaleUp scales up newRc to desired by whatever increment is possible given +// the configured surge threshold. scaleUp will safely no-op as necessary when +// it detects redundancy or other relevant conditions. +func (r *RollingUpdater) scaleUp(newRc, oldRc *corev1.ReplicationController, desired, maxSurge, maxUnavailable int32, scaleRetryParams *scale.RetryParams, config *RollingUpdaterConfig) (*corev1.ReplicationController, error) { + // If we're already at the desired, do nothing. + if valOrZero(newRc.Spec.Replicas) == desired { + return newRc, nil + } + + // Scale up as far as we can based on the surge limit. + increment := (desired + maxSurge) - (valOrZero(oldRc.Spec.Replicas) + valOrZero(newRc.Spec.Replicas)) + // If the old is already scaled down, go ahead and scale all the way up. + if valOrZero(oldRc.Spec.Replicas) == 0 { + increment = desired - valOrZero(newRc.Spec.Replicas) + } + // We can't scale up without violating the surge limit, so do nothing. + if increment <= 0 { + return newRc, nil + } + // Increase the replica count, and deal with fenceposts. + nextVal := valOrZero(newRc.Spec.Replicas) + increment + newRc.Spec.Replicas = &nextVal + if valOrZero(newRc.Spec.Replicas) > desired { + newRc.Spec.Replicas = &desired + } + // Perform the scale-up. + fmt.Fprintf(config.Out, "Scaling %s up to %d\n", newRc.Name, valOrZero(newRc.Spec.Replicas)) + scaledRc, err := r.scaleAndWait(newRc, scaleRetryParams, scaleRetryParams) + if err != nil { + return nil, err + } + return scaledRc, nil +} + +// scaleDown scales down oldRc to 0 at whatever decrement possible given the +// thresholds defined on the config. scaleDown will safely no-op as necessary +// when it detects redundancy or other relevant conditions. +func (r *RollingUpdater) scaleDown(newRc, oldRc *corev1.ReplicationController, desired, minAvailable, maxUnavailable, maxSurge int32, config *RollingUpdaterConfig) (*corev1.ReplicationController, error) { + // Already scaled down; do nothing. + if valOrZero(oldRc.Spec.Replicas) == 0 { + return oldRc, nil + } + // Get ready pods. We shouldn't block, otherwise in case both old and new + // pods are unavailable then the rolling update process blocks. + // Timeout-wise we are already covered by the progress check. + _, newAvailable, err := r.getReadyPods(oldRc, newRc, config.MinReadySeconds) + if err != nil { + return nil, err + } + // The old controller is considered as part of the total because we want to + // maintain minimum availability even with a volatile old controller. + // Scale down as much as possible while maintaining minimum availability + allPods := valOrZero(oldRc.Spec.Replicas) + valOrZero(newRc.Spec.Replicas) + newUnavailable := valOrZero(newRc.Spec.Replicas) - newAvailable + decrement := allPods - minAvailable - newUnavailable + // The decrement normally shouldn't drop below 0 because the available count + // always starts below the old replica count, but the old replica count can + // decrement due to externalities like pods death in the replica set. This + // will be considered a transient condition; do nothing and try again later + // with new readiness values. + // + // If the most we can scale is 0, it means we can't scale down without + // violating the minimum. Do nothing and try again later when conditions may + // have changed. + if decrement <= 0 { + return oldRc, nil + } + // Reduce the replica count, and deal with fenceposts. + nextOldVal := valOrZero(oldRc.Spec.Replicas) - decrement + oldRc.Spec.Replicas = &nextOldVal + if valOrZero(oldRc.Spec.Replicas) < 0 { + oldRc.Spec.Replicas = utilpointer.Int32Ptr(0) + } + // If the new is already fully scaled and available up to the desired size, go + // ahead and scale old all the way down. + if valOrZero(newRc.Spec.Replicas) == desired && newAvailable == desired { + oldRc.Spec.Replicas = utilpointer.Int32Ptr(0) + } + // Perform the scale-down. + fmt.Fprintf(config.Out, "Scaling %s down to %d\n", oldRc.Name, valOrZero(oldRc.Spec.Replicas)) + retryWait := &scale.RetryParams{ + Interval: config.Interval, + Timeout: config.Timeout, + } + scaledRc, err := r.scaleAndWait(oldRc, retryWait, retryWait) + if err != nil { + return nil, err + } + return scaledRc, nil +} + +// scalerScaleAndWait scales a controller using a Scaler and a real client. +func (r *RollingUpdater) scaleAndWaitWithScaler(rc *corev1.ReplicationController, retry *scale.RetryParams, wait *scale.RetryParams) (*corev1.ReplicationController, error) { + scaler := scale.NewScaler(r.scaleClient) + if err := scaler.Scale(rc.Namespace, rc.Name, uint(valOrZero(rc.Spec.Replicas)), &scale.ScalePrecondition{Size: -1, ResourceVersion: ""}, retry, wait, schema.GroupResource{Resource: "replicationcontrollers"}); err != nil { + return nil, err + } + return r.rcClient.ReplicationControllers(rc.Namespace).Get(rc.Name, metav1.GetOptions{}) +} + +// readyPods returns the old and new ready counts for their pods. +// If a pod is observed as being ready, it's considered ready even +// if it later becomes notReady. +func (r *RollingUpdater) readyPods(oldRc, newRc *corev1.ReplicationController, minReadySeconds int32) (int32, int32, error) { + controllers := []*corev1.ReplicationController{oldRc, newRc} + oldReady := int32(0) + newReady := int32(0) + if r.nowFn == nil { + r.nowFn = func() metav1.Time { return metav1.Now() } + } + + for i := range controllers { + controller := controllers[i] + selector := labels.Set(controller.Spec.Selector).AsSelector() + options := metav1.ListOptions{LabelSelector: selector.String()} + pods, err := r.podClient.Pods(controller.Namespace).List(options) + if err != nil { + return 0, 0, err + } + for _, v1Pod := range pods.Items { + // Do not count deleted pods as ready + if v1Pod.DeletionTimestamp != nil { + continue + } + if !podutils.IsPodAvailable(&v1Pod, minReadySeconds, r.nowFn()) { + continue + } + switch controller.Name { + case oldRc.Name: + oldReady++ + case newRc.Name: + newReady++ + } + } + } + return oldReady, newReady, nil +} + +// getOrCreateTargetControllerWithClient looks for an existing controller with +// sourceID. If found, the existing controller is returned with true +// indicating that the controller already exists. If the controller isn't +// found, a new one is created and returned along with false indicating the +// controller was created. +// +// Existing controllers are validated to ensure their sourceIDAnnotation +// matches sourceID; if there's a mismatch, an error is returned. +func (r *RollingUpdater) getOrCreateTargetControllerWithClient(controller *corev1.ReplicationController, sourceID string) (*corev1.ReplicationController, bool, error) { + existingRc, err := r.existingController(controller) + if err != nil { + if !errors.IsNotFound(err) { + // There was an error trying to find the controller; don't assume we + // should create it. + return nil, false, err + } + if valOrZero(controller.Spec.Replicas) <= 0 { + return nil, false, fmt.Errorf("Invalid controller spec for %s; required: > 0 replicas, actual: %d", controller.Name, valOrZero(controller.Spec.Replicas)) + } + // The controller wasn't found, so create it. + if controller.Annotations == nil { + controller.Annotations = map[string]string{} + } + controller.Annotations[desiredReplicasAnnotation] = fmt.Sprintf("%d", valOrZero(controller.Spec.Replicas)) + controller.Annotations[sourceIDAnnotation] = sourceID + controller.Spec.Replicas = utilpointer.Int32Ptr(0) + newRc, err := r.rcClient.ReplicationControllers(r.ns).Create(controller) + return newRc, false, err + } + // Validate and use the existing controller. + annotations := existingRc.Annotations + source := annotations[sourceIDAnnotation] + _, ok := annotations[desiredReplicasAnnotation] + if source != sourceID || !ok { + return nil, false, fmt.Errorf("Missing/unexpected annotations for controller %s, expected %s : %s", controller.Name, sourceID, annotations) + } + return existingRc, true, nil +} + +// existingController verifies if the controller already exists +func (r *RollingUpdater) existingController(controller *corev1.ReplicationController) (*corev1.ReplicationController, error) { + // without rc name but generate name, there's no existing rc + if len(controller.Name) == 0 && len(controller.GenerateName) > 0 { + return nil, errors.NewNotFound(corev1.Resource("replicationcontrollers"), controller.Name) + } + // controller name is required to get rc back + return r.rcClient.ReplicationControllers(controller.Namespace).Get(controller.Name, metav1.GetOptions{}) +} + +// cleanupWithClients performs cleanup tasks after the rolling update. Update +// process related annotations are removed from oldRc and newRc. The +// CleanupPolicy on config is executed. +func (r *RollingUpdater) cleanupWithClients(oldRc, newRc *corev1.ReplicationController, config *RollingUpdaterConfig) error { + // Clean up annotations + var err error + newRc, err = r.rcClient.ReplicationControllers(r.ns).Get(newRc.Name, metav1.GetOptions{}) + if err != nil { + return err + } + applyUpdate := func(rc *corev1.ReplicationController) { + delete(rc.Annotations, sourceIDAnnotation) + delete(rc.Annotations, desiredReplicasAnnotation) + } + if newRc, err = updateRcWithRetries(r.rcClient, r.ns, newRc, applyUpdate); err != nil { + return err + } + + if err = wait.Poll(config.Interval, config.Timeout, controllerHasDesiredReplicas(r.rcClient, newRc)); err != nil { + return err + } + newRc, err = r.rcClient.ReplicationControllers(r.ns).Get(newRc.Name, metav1.GetOptions{}) + if err != nil { + return err + } + + switch config.CleanupPolicy { + case DeleteRollingUpdateCleanupPolicy: + // delete old rc + fmt.Fprintf(config.Out, "Update succeeded. Deleting %s\n", oldRc.Name) + return r.rcClient.ReplicationControllers(r.ns).Delete(oldRc.Name, nil) + case RenameRollingUpdateCleanupPolicy: + // delete old rc + fmt.Fprintf(config.Out, "Update succeeded. Deleting old controller: %s\n", oldRc.Name) + if err := r.rcClient.ReplicationControllers(r.ns).Delete(oldRc.Name, nil); err != nil { + return err + } + fmt.Fprintf(config.Out, "Renaming %s to %s\n", newRc.Name, oldRc.Name) + return Rename(r.rcClient, newRc, oldRc.Name) + case PreserveRollingUpdateCleanupPolicy: + return nil + default: + return nil + } +} + +func Rename(c corev1client.ReplicationControllersGetter, rc *corev1.ReplicationController, newName string) error { + oldName := rc.Name + rc.Name = newName + rc.ResourceVersion = "" + // First delete the oldName RC and orphan its pods. + policy := metav1.DeletePropagationOrphan + err := c.ReplicationControllers(rc.Namespace).Delete(oldName, &metav1.DeleteOptions{PropagationPolicy: &policy}) + if err != nil && !errors.IsNotFound(err) { + return err + } + err = wait.Poll(5*time.Second, 60*time.Second, func() (bool, error) { + _, err := c.ReplicationControllers(rc.Namespace).Get(oldName, metav1.GetOptions{}) + if err == nil { + return false, nil + } else if errors.IsNotFound(err) { + return true, nil + } else { + return false, err + } + }) + if err != nil { + return err + } + // Then create the same RC with the new name. + _, err = c.ReplicationControllers(rc.Namespace).Create(rc) + return err +} + +func LoadExistingNextReplicationController(c corev1client.ReplicationControllersGetter, namespace, newName string) (*corev1.ReplicationController, error) { + if len(newName) == 0 { + return nil, nil + } + newRc, err := c.ReplicationControllers(namespace).Get(newName, metav1.GetOptions{}) + if err != nil && errors.IsNotFound(err) { + return nil, nil + } + return newRc, err +} + +type NewControllerConfig struct { + Namespace string + OldName, NewName string + Image string + Container string + DeploymentKey string + PullPolicy corev1.PullPolicy +} + +func CreateNewControllerFromCurrentController(rcClient corev1client.ReplicationControllersGetter, codec runtime.Codec, cfg *NewControllerConfig) (*corev1.ReplicationController, error) { + containerIndex := 0 + // load the old RC into the "new" RC + newRc, err := rcClient.ReplicationControllers(cfg.Namespace).Get(cfg.OldName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + if len(cfg.Container) != 0 { + containerFound := false + + for i, c := range newRc.Spec.Template.Spec.Containers { + if c.Name == cfg.Container { + containerIndex = i + containerFound = true + break + } + } + + if !containerFound { + return nil, fmt.Errorf("container %s not found in pod", cfg.Container) + } + } + + if len(newRc.Spec.Template.Spec.Containers) > 1 && len(cfg.Container) == 0 { + return nil, fmt.Errorf("must specify container to update when updating a multi-container pod") + } + + if len(newRc.Spec.Template.Spec.Containers) == 0 { + return nil, fmt.Errorf("pod has no containers! (%v)", newRc) + } + newRc.Spec.Template.Spec.Containers[containerIndex].Image = cfg.Image + if len(cfg.PullPolicy) != 0 { + newRc.Spec.Template.Spec.Containers[containerIndex].ImagePullPolicy = cfg.PullPolicy + } + + newHash, err := util.HashObject(newRc, codec) + if err != nil { + return nil, err + } + + if len(cfg.NewName) == 0 { + cfg.NewName = fmt.Sprintf("%s-%s", newRc.Name, newHash) + } + newRc.Name = cfg.NewName + + newRc.Spec.Selector[cfg.DeploymentKey] = newHash + newRc.Spec.Template.Labels[cfg.DeploymentKey] = newHash + // Clear resource version after hashing so that identical updates get different hashes. + newRc.ResourceVersion = "" + return newRc, nil +} + +func AbortRollingUpdate(c *RollingUpdaterConfig) error { + // Swap the controllers + tmp := c.OldRc + c.OldRc = c.NewRc + c.NewRc = tmp + + if c.NewRc.Annotations == nil { + c.NewRc.Annotations = map[string]string{} + } + c.NewRc.Annotations[sourceIDAnnotation] = fmt.Sprintf("%s:%s", c.OldRc.Name, c.OldRc.UID) + + // Use the original value since the replica count change from old to new + // could be asymmetric. If we don't know the original count, we can't safely + // roll back to a known good size. + originalSize, foundOriginal := tmp.Annotations[originalReplicasAnnotation] + if !foundOriginal { + return fmt.Errorf("couldn't find original replica count of %q", tmp.Name) + } + fmt.Fprintf(c.Out, "Setting %q replicas to %s\n", c.NewRc.Name, originalSize) + c.NewRc.Annotations[desiredReplicasAnnotation] = originalSize + c.CleanupPolicy = DeleteRollingUpdateCleanupPolicy + return nil +} + +func GetNextControllerAnnotation(rc *corev1.ReplicationController) (string, bool) { + res, found := rc.Annotations[nextControllerAnnotation] + return res, found +} + +func SetNextControllerAnnotation(rc *corev1.ReplicationController, name string) { + if rc.Annotations == nil { + rc.Annotations = map[string]string{} + } + rc.Annotations[nextControllerAnnotation] = name +} + +func UpdateExistingReplicationController(rcClient corev1client.ReplicationControllersGetter, podClient corev1client.PodsGetter, oldRc *corev1.ReplicationController, namespace, newName, deploymentKey, deploymentValue string, out io.Writer) (*corev1.ReplicationController, error) { + if _, found := oldRc.Spec.Selector[deploymentKey]; !found { + SetNextControllerAnnotation(oldRc, newName) + return AddDeploymentKeyToReplicationController(oldRc, rcClient, podClient, deploymentKey, deploymentValue, namespace, out) + } + + // If we didn't need to update the controller for the deployment key, we still need to write + // the "next" controller. + applyUpdate := func(rc *corev1.ReplicationController) { + SetNextControllerAnnotation(rc, newName) + } + return updateRcWithRetries(rcClient, namespace, oldRc, applyUpdate) +} + +func AddDeploymentKeyToReplicationController(oldRc *corev1.ReplicationController, rcClient corev1client.ReplicationControllersGetter, podClient corev1client.PodsGetter, deploymentKey, deploymentValue, namespace string, out io.Writer) (*corev1.ReplicationController, error) { + var err error + // First, update the template label. This ensures that any newly created pods will have the new label + applyUpdate := func(rc *corev1.ReplicationController) { + if rc.Spec.Template.Labels == nil { + rc.Spec.Template.Labels = map[string]string{} + } + rc.Spec.Template.Labels[deploymentKey] = deploymentValue + } + if oldRc, err = updateRcWithRetries(rcClient, namespace, oldRc, applyUpdate); err != nil { + return nil, err + } + + // Update all pods managed by the rc to have the new hash label, so they are correctly adopted + // TODO: extract the code from the label command and re-use it here. + selector := labels.SelectorFromSet(oldRc.Spec.Selector) + options := metav1.ListOptions{LabelSelector: selector.String()} + podList, err := podClient.Pods(namespace).List(options) + if err != nil { + return nil, err + } + for ix := range podList.Items { + pod := &podList.Items[ix] + applyUpdate := func(p *corev1.Pod) { + if p.Labels == nil { + p.Labels = map[string]string{ + deploymentKey: deploymentValue, + } + } else { + p.Labels[deploymentKey] = deploymentValue + } + } + if pod, err = updatePodWithRetries(podClient, namespace, pod, applyUpdate); err != nil { + return nil, err + } + } + + if oldRc.Spec.Selector == nil { + oldRc.Spec.Selector = map[string]string{} + } + applyUpdate = func(rc *corev1.ReplicationController) { + rc.Spec.Selector[deploymentKey] = deploymentValue + } + // Update the selector of the rc so it manages all the pods we updated above + if oldRc, err = updateRcWithRetries(rcClient, namespace, oldRc, applyUpdate); err != nil { + return nil, err + } + + // Clean up any orphaned pods that don't have the new label, this can happen if the rc manager + // doesn't see the update to its pod template and creates a new pod with the old labels after + // we've finished re-adopting existing pods to the rc. + selector = labels.SelectorFromSet(oldRc.Spec.Selector) + options = metav1.ListOptions{LabelSelector: selector.String()} + if podList, err = podClient.Pods(namespace).List(options); err != nil { + return nil, err + } + for ix := range podList.Items { + pod := &podList.Items[ix] + if value, found := pod.Labels[deploymentKey]; !found || value != deploymentValue { + if err := podClient.Pods(namespace).Delete(pod.Name, nil); err != nil { + return nil, err + } + } + } + + return oldRc, nil +} + +type updateRcFunc func(controller *corev1.ReplicationController) + +// updateRcWithRetries retries updating the given rc on conflict with the following steps: +// 1. Get latest resource +// 2. applyUpdate +// 3. Update the resource +func updateRcWithRetries(rcClient corev1client.ReplicationControllersGetter, namespace string, rc *corev1.ReplicationController, applyUpdate updateRcFunc) (*corev1.ReplicationController, error) { + // Deep copy the rc in case we failed on Get during retry loop + oldRc := rc.DeepCopy() + err := retry.RetryOnConflict(retry.DefaultBackoff, func() (e error) { + // Apply the update, then attempt to push it to the apiserver. + applyUpdate(rc) + if rc, e = rcClient.ReplicationControllers(namespace).Update(rc); e == nil { + // rc contains the latest controller post update + return + } + updateErr := e + // Update the controller with the latest resource version, if the update failed we + // can't trust rc so use oldRc.Name. + if rc, e = rcClient.ReplicationControllers(namespace).Get(oldRc.Name, metav1.GetOptions{}); e != nil { + // The Get failed: Value in rc cannot be trusted. + rc = oldRc + } + // Only return the error from update + return updateErr + }) + // If the error is non-nil the returned controller cannot be trusted, if it is nil, the returned + // controller contains the applied update. + return rc, err +} + +type updatePodFunc func(controller *corev1.Pod) + +// updatePodWithRetries retries updating the given pod on conflict with the following steps: +// 1. Get latest resource +// 2. applyUpdate +// 3. Update the resource +func updatePodWithRetries(podClient corev1client.PodsGetter, namespace string, pod *corev1.Pod, applyUpdate updatePodFunc) (*corev1.Pod, error) { + // Deep copy the pod in case we failed on Get during retry loop + oldPod := pod.DeepCopy() + err := retry.RetryOnConflict(retry.DefaultBackoff, func() (e error) { + // Apply the update, then attempt to push it to the apiserver. + applyUpdate(pod) + if pod, e = podClient.Pods(namespace).Update(pod); e == nil { + return + } + updateErr := e + if pod, e = podClient.Pods(namespace).Get(oldPod.Name, metav1.GetOptions{}); e != nil { + pod = oldPod + } + // Only return the error from update + return updateErr + }) + // If the error is non-nil the returned pod cannot be trusted, if it is nil, the returned + // controller contains the applied update. + return pod, err +} + +func FindSourceController(r corev1client.ReplicationControllersGetter, namespace, name string) (*corev1.ReplicationController, error) { + list, err := r.ReplicationControllers(namespace).List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + for ix := range list.Items { + rc := &list.Items[ix] + if rc.Annotations != nil && strings.HasPrefix(rc.Annotations[sourceIDAnnotation], name) { + return rc, nil + } + } + return nil, fmt.Errorf("couldn't find a replication controller with source id == %s/%s", namespace, name) +} + +// controllerHasDesiredReplicas returns a condition that will be true if and only if +// the desired replica count for a controller's ReplicaSelector equals the Replicas count. +func controllerHasDesiredReplicas(rcClient corev1client.ReplicationControllersGetter, controller *corev1.ReplicationController) wait.ConditionFunc { + + // If we're given a controller where the status lags the spec, it either means that the controller is stale, + // or that the rc manager hasn't noticed the update yet. Polling status.Replicas is not safe in the latter case. + desiredGeneration := controller.Generation + + return func() (bool, error) { + ctrl, err := rcClient.ReplicationControllers(controller.Namespace).Get(controller.Name, metav1.GetOptions{}) + if err != nil { + return false, err + } + // There's a chance a concurrent update modifies the Spec.Replicas causing this check to pass, + // or, after this check has passed, a modification causes the rc manager to create more pods. + // This will not be an issue once we've implemented graceful delete for rcs, but till then + // concurrent stop operations on the same rc might have unintended side effects. + return ctrl.Status.ObservedGeneration >= desiredGeneration && ctrl.Status.Replicas == valOrZero(ctrl.Spec.Replicas), nil + } +} diff --git a/pkg/cmd/rollingupdate/rolling_updater_test.go b/pkg/cmd/rollingupdate/rolling_updater_test.go new file mode 100644 index 000000000..0278392fd --- /dev/null +++ b/pkg/cmd/rollingupdate/rolling_updater_test.go @@ -0,0 +1,1852 @@ +/* +Copyright 2014 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 rollingupdate + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "reflect" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/diff" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + restclient "k8s.io/client-go/rest" + manualfake "k8s.io/client-go/rest/fake" + testcore "k8s.io/client-go/testing" + "k8s.io/kubectl/pkg/scale" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" +) + +func oldRc(replicas int, original int) *corev1.ReplicationController { + t := replicas + replicasCopy := int32(t) + return &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "foo-v1", + UID: "7764ae47-9092-11e4-8393-42010af018ff", + Annotations: map[string]string{ + originalReplicasAnnotation: fmt.Sprintf("%d", original), + }, + }, + Spec: corev1.ReplicationControllerSpec{ + Replicas: &replicasCopy, + Selector: map[string]string{"version": "v1"}, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-v1", + Labels: map[string]string{"version": "v1"}, + }, + }, + }, + Status: corev1.ReplicationControllerStatus{ + Replicas: int32(replicas), + }, + } +} + +func newRc(replicas int, desired int) *corev1.ReplicationController { + rc := oldRc(replicas, replicas) + rc.Spec.Template = &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-v2", + Labels: map[string]string{"version": "v2"}, + }, + } + rc.Spec.Selector = map[string]string{"version": "v2"} + rc.ObjectMeta = metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "foo-v2", + Annotations: map[string]string{ + desiredReplicasAnnotation: fmt.Sprintf("%d", desired), + sourceIDAnnotation: "foo-v1:7764ae47-9092-11e4-8393-42010af018ff", + }, + } + return rc +} + +// TestUpdate performs complex scenario testing for rolling updates. It +// provides fine grained control over the states for each update interval to +// allow the expression of as many edge cases as possible. +func TestUpdate(t *testing.T) { + // up represents a simulated scale up event and expectation + type up struct { + // to is the expected replica count for a scale-up + to int + } + // down represents a simulated scale down event and expectation + type down struct { + // oldReady is the number of oldRc replicas which will be seen + // as ready during the scale down attempt + oldReady int + // newReady is the number of newRc replicas which will be seen + // as ready during the scale up attempt + newReady int + // to is the expected replica count for the scale down + to int + // noop and to are mutually exclusive; if noop is true, that means for + // this down event, no scaling attempt should be made (for example, if + // by scaling down, the readiness minimum would be crossed.) + noop bool + } + + tests := []struct { + name string + // oldRc is the "from" deployment + oldRc *corev1.ReplicationController + // newRc is the "to" deployment + newRc *corev1.ReplicationController + // whether newRc existed (false means it was created) + newRcExists bool + maxUnavail intstr.IntOrString + maxSurge intstr.IntOrString + // expected is the sequence of up/down events that will be simulated and + // verified + expected []interface{} + // output is the expected textual output written + output string + }{ + { + name: "10->10 30/0 fast readiness", + oldRc: oldRc(10, 10), + newRc: newRc(0, 10), + newRcExists: false, + maxUnavail: intstr.FromString("30%"), + maxSurge: intstr.FromString("0%"), + expected: []interface{}{ + down{oldReady: 10, newReady: 0, to: 7}, + up{3}, + down{oldReady: 7, newReady: 3, to: 4}, + up{6}, + down{oldReady: 4, newReady: 6, to: 1}, + up{9}, + down{oldReady: 1, newReady: 9, to: 0}, + up{10}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 10, scaling down foo-v1 from 10 to 0 (keep 7 pods available, don't exceed 10 pods) +Scaling foo-v1 down to 7 +Scaling foo-v2 up to 3 +Scaling foo-v1 down to 4 +Scaling foo-v2 up to 6 +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 9 +Scaling foo-v1 down to 0 +Scaling foo-v2 up to 10 +`, + }, + { + name: "10->10 30/0 delayed readiness", + oldRc: oldRc(10, 10), + newRc: newRc(0, 10), + newRcExists: false, + maxUnavail: intstr.FromString("30%"), + maxSurge: intstr.FromString("0%"), + expected: []interface{}{ + down{oldReady: 10, newReady: 0, to: 7}, + up{3}, + down{oldReady: 7, newReady: 0, noop: true}, + down{oldReady: 7, newReady: 1, to: 6}, + up{4}, + down{oldReady: 6, newReady: 4, to: 3}, + up{7}, + down{oldReady: 3, newReady: 7, to: 0}, + up{10}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 10, scaling down foo-v1 from 10 to 0 (keep 7 pods available, don't exceed 10 pods) +Scaling foo-v1 down to 7 +Scaling foo-v2 up to 3 +Scaling foo-v1 down to 6 +Scaling foo-v2 up to 4 +Scaling foo-v1 down to 3 +Scaling foo-v2 up to 7 +Scaling foo-v1 down to 0 +Scaling foo-v2 up to 10 +`, + }, { + name: "10->10 30/0 fast readiness, continuation", + oldRc: oldRc(7, 10), + newRc: newRc(3, 10), + newRcExists: false, + maxUnavail: intstr.FromString("30%"), + maxSurge: intstr.FromString("0%"), + expected: []interface{}{ + down{oldReady: 7, newReady: 3, to: 4}, + up{6}, + down{oldReady: 4, newReady: 6, to: 1}, + up{9}, + down{oldReady: 1, newReady: 9, to: 0}, + up{10}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 3 to 10, scaling down foo-v1 from 7 to 0 (keep 7 pods available, don't exceed 10 pods) +Scaling foo-v1 down to 4 +Scaling foo-v2 up to 6 +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 9 +Scaling foo-v1 down to 0 +Scaling foo-v2 up to 10 +`, + }, { + name: "10->10 30/0 fast readiness, continued after restart which prevented first scale-up", + oldRc: oldRc(7, 10), + newRc: newRc(0, 10), + newRcExists: false, + maxUnavail: intstr.FromString("30%"), + maxSurge: intstr.FromString("0%"), + expected: []interface{}{ + down{oldReady: 7, newReady: 0, noop: true}, + up{3}, + down{oldReady: 7, newReady: 3, to: 4}, + up{6}, + down{oldReady: 4, newReady: 6, to: 1}, + up{9}, + down{oldReady: 1, newReady: 9, to: 0}, + up{10}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 10, scaling down foo-v1 from 7 to 0 (keep 7 pods available, don't exceed 10 pods) +Scaling foo-v2 up to 3 +Scaling foo-v1 down to 4 +Scaling foo-v2 up to 6 +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 9 +Scaling foo-v1 down to 0 +Scaling foo-v2 up to 10 +`, + }, { + name: "10->10 0/30 fast readiness", + oldRc: oldRc(10, 10), + newRc: newRc(0, 10), + newRcExists: false, + maxUnavail: intstr.FromString("0%"), + maxSurge: intstr.FromString("30%"), + expected: []interface{}{ + up{3}, + down{oldReady: 10, newReady: 3, to: 7}, + up{6}, + down{oldReady: 7, newReady: 6, to: 4}, + up{9}, + down{oldReady: 4, newReady: 9, to: 1}, + up{10}, + down{oldReady: 1, newReady: 10, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 10, scaling down foo-v1 from 10 to 0 (keep 10 pods available, don't exceed 13 pods) +Scaling foo-v2 up to 3 +Scaling foo-v1 down to 7 +Scaling foo-v2 up to 6 +Scaling foo-v1 down to 4 +Scaling foo-v2 up to 9 +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 10 +Scaling foo-v1 down to 0 +`, + }, { + name: "10->10 0/30 delayed readiness", + oldRc: oldRc(10, 10), + newRc: newRc(0, 10), + newRcExists: false, + maxUnavail: intstr.FromString("0%"), + maxSurge: intstr.FromString("30%"), + expected: []interface{}{ + up{3}, + down{oldReady: 10, newReady: 0, noop: true}, + down{oldReady: 10, newReady: 1, to: 9}, + up{4}, + down{oldReady: 9, newReady: 3, to: 7}, + up{6}, + down{oldReady: 7, newReady: 6, to: 4}, + up{9}, + down{oldReady: 4, newReady: 9, to: 1}, + up{10}, + down{oldReady: 1, newReady: 9, noop: true}, + down{oldReady: 1, newReady: 10, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 10, scaling down foo-v1 from 10 to 0 (keep 10 pods available, don't exceed 13 pods) +Scaling foo-v2 up to 3 +Scaling foo-v1 down to 9 +Scaling foo-v2 up to 4 +Scaling foo-v1 down to 7 +Scaling foo-v2 up to 6 +Scaling foo-v1 down to 4 +Scaling foo-v2 up to 9 +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 10 +Scaling foo-v1 down to 0 +`, + }, { + name: "10->10 10/20 fast readiness", + oldRc: oldRc(10, 10), + newRc: newRc(0, 10), + newRcExists: false, + maxUnavail: intstr.FromString("10%"), + maxSurge: intstr.FromString("20%"), + expected: []interface{}{ + up{2}, + down{oldReady: 10, newReady: 2, to: 7}, + up{5}, + down{oldReady: 7, newReady: 5, to: 4}, + up{8}, + down{oldReady: 4, newReady: 8, to: 1}, + up{10}, + down{oldReady: 1, newReady: 10, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 10, scaling down foo-v1 from 10 to 0 (keep 9 pods available, don't exceed 12 pods) +Scaling foo-v2 up to 2 +Scaling foo-v1 down to 7 +Scaling foo-v2 up to 5 +Scaling foo-v1 down to 4 +Scaling foo-v2 up to 8 +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 10 +Scaling foo-v1 down to 0 +`, + }, { + name: "10->10 10/20 delayed readiness", + oldRc: oldRc(10, 10), + newRc: newRc(0, 10), + newRcExists: false, + maxUnavail: intstr.FromString("10%"), + maxSurge: intstr.FromString("20%"), + expected: []interface{}{ + up{2}, + down{oldReady: 10, newReady: 2, to: 7}, + up{5}, + down{oldReady: 7, newReady: 4, to: 5}, + up{7}, + down{oldReady: 5, newReady: 4, noop: true}, + down{oldReady: 5, newReady: 7, to: 2}, + up{10}, + down{oldReady: 2, newReady: 9, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 10, scaling down foo-v1 from 10 to 0 (keep 9 pods available, don't exceed 12 pods) +Scaling foo-v2 up to 2 +Scaling foo-v1 down to 7 +Scaling foo-v2 up to 5 +Scaling foo-v1 down to 5 +Scaling foo-v2 up to 7 +Scaling foo-v1 down to 2 +Scaling foo-v2 up to 10 +Scaling foo-v1 down to 0 +`, + }, { + name: "10->10 10/20 fast readiness continued after restart which prevented first scale-down", + oldRc: oldRc(10, 10), + newRc: newRc(2, 10), + newRcExists: false, + maxUnavail: intstr.FromString("10%"), + maxSurge: intstr.FromString("20%"), + expected: []interface{}{ + down{oldReady: 10, newReady: 2, to: 7}, + up{5}, + down{oldReady: 7, newReady: 5, to: 4}, + up{8}, + down{oldReady: 4, newReady: 8, to: 1}, + up{10}, + down{oldReady: 1, newReady: 10, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 2 to 10, scaling down foo-v1 from 10 to 0 (keep 9 pods available, don't exceed 12 pods) +Scaling foo-v1 down to 7 +Scaling foo-v2 up to 5 +Scaling foo-v1 down to 4 +Scaling foo-v2 up to 8 +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 10 +Scaling foo-v1 down to 0 +`, + }, { + name: "10->10 0/100 fast readiness", + oldRc: oldRc(10, 10), + newRc: newRc(0, 10), + newRcExists: false, + maxUnavail: intstr.FromString("0%"), + maxSurge: intstr.FromString("100%"), + expected: []interface{}{ + up{10}, + down{oldReady: 10, newReady: 10, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 10, scaling down foo-v1 from 10 to 0 (keep 10 pods available, don't exceed 20 pods) +Scaling foo-v2 up to 10 +Scaling foo-v1 down to 0 +`, + }, { + name: "10->10 0/100 delayed readiness", + oldRc: oldRc(10, 10), + newRc: newRc(0, 10), + newRcExists: false, + maxUnavail: intstr.FromString("0%"), + maxSurge: intstr.FromString("100%"), + expected: []interface{}{ + up{10}, + down{oldReady: 10, newReady: 0, noop: true}, + down{oldReady: 10, newReady: 2, to: 8}, + down{oldReady: 8, newReady: 7, to: 3}, + down{oldReady: 3, newReady: 10, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 10, scaling down foo-v1 from 10 to 0 (keep 10 pods available, don't exceed 20 pods) +Scaling foo-v2 up to 10 +Scaling foo-v1 down to 8 +Scaling foo-v1 down to 3 +Scaling foo-v1 down to 0 +`, + }, { + name: "10->10 100/0 fast readiness", + oldRc: oldRc(10, 10), + newRc: newRc(0, 10), + newRcExists: false, + maxUnavail: intstr.FromString("100%"), + maxSurge: intstr.FromString("0%"), + expected: []interface{}{ + down{oldReady: 10, newReady: 0, to: 0}, + up{10}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 10, scaling down foo-v1 from 10 to 0 (keep 0 pods available, don't exceed 10 pods) +Scaling foo-v1 down to 0 +Scaling foo-v2 up to 10 +`, + }, { + name: "1->1 25/25 maintain minimum availability", + oldRc: oldRc(1, 1), + newRc: newRc(0, 1), + newRcExists: false, + maxUnavail: intstr.FromString("25%"), + maxSurge: intstr.FromString("25%"), + expected: []interface{}{ + up{1}, + down{oldReady: 1, newReady: 0, noop: true}, + down{oldReady: 1, newReady: 1, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 1, scaling down foo-v1 from 1 to 0 (keep 1 pods available, don't exceed 2 pods) +Scaling foo-v2 up to 1 +Scaling foo-v1 down to 0 +`, + }, { + name: "1->1 0/10 delayed readiness", + oldRc: oldRc(1, 1), + newRc: newRc(0, 1), + newRcExists: false, + maxUnavail: intstr.FromString("0%"), + maxSurge: intstr.FromString("10%"), + expected: []interface{}{ + up{1}, + down{oldReady: 1, newReady: 0, noop: true}, + down{oldReady: 1, newReady: 1, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 1, scaling down foo-v1 from 1 to 0 (keep 1 pods available, don't exceed 2 pods) +Scaling foo-v2 up to 1 +Scaling foo-v1 down to 0 +`, + }, { + name: "1->1 10/10 delayed readiness", + oldRc: oldRc(1, 1), + newRc: newRc(0, 1), + newRcExists: false, + maxUnavail: intstr.FromString("10%"), + maxSurge: intstr.FromString("10%"), + expected: []interface{}{ + up{1}, + down{oldReady: 1, newReady: 0, noop: true}, + down{oldReady: 1, newReady: 1, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 1, scaling down foo-v1 from 1 to 0 (keep 1 pods available, don't exceed 2 pods) +Scaling foo-v2 up to 1 +Scaling foo-v1 down to 0 +`, + }, { + name: "3->3 1/1 fast readiness (absolute values)", + oldRc: oldRc(3, 3), + newRc: newRc(0, 3), + newRcExists: false, + maxUnavail: intstr.FromInt(0), + maxSurge: intstr.FromInt(1), + expected: []interface{}{ + up{1}, + down{oldReady: 3, newReady: 1, to: 2}, + up{2}, + down{oldReady: 2, newReady: 2, to: 1}, + up{3}, + down{oldReady: 1, newReady: 3, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 3, scaling down foo-v1 from 3 to 0 (keep 3 pods available, don't exceed 4 pods) +Scaling foo-v2 up to 1 +Scaling foo-v1 down to 2 +Scaling foo-v2 up to 2 +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 3 +Scaling foo-v1 down to 0 +`, + }, { + name: "10->10 0/20 fast readiness, continued after restart which resulted in partial first scale-up", + oldRc: oldRc(6, 10), + newRc: newRc(5, 10), + newRcExists: false, + maxUnavail: intstr.FromString("0%"), + maxSurge: intstr.FromString("20%"), + expected: []interface{}{ + up{6}, + down{oldReady: 6, newReady: 6, to: 4}, + up{8}, + down{oldReady: 4, newReady: 8, to: 2}, + up{10}, + down{oldReady: 1, newReady: 10, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 5 to 10, scaling down foo-v1 from 6 to 0 (keep 10 pods available, don't exceed 12 pods) +Scaling foo-v2 up to 6 +Scaling foo-v1 down to 4 +Scaling foo-v2 up to 8 +Scaling foo-v1 down to 2 +Scaling foo-v2 up to 10 +Scaling foo-v1 down to 0 +`, + }, { + name: "10->20 0/300 fast readiness", + oldRc: oldRc(10, 10), + newRc: newRc(0, 20), + newRcExists: false, + maxUnavail: intstr.FromString("0%"), + maxSurge: intstr.FromString("300%"), + expected: []interface{}{ + up{20}, + down{oldReady: 10, newReady: 20, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 20, scaling down foo-v1 from 10 to 0 (keep 20 pods available, don't exceed 80 pods) +Scaling foo-v2 up to 20 +Scaling foo-v1 down to 0 +`, + }, { + name: "1->1 0/1 scale down unavailable rc to a ready rc (rollback)", + oldRc: oldRc(1, 1), + newRc: newRc(1, 1), + newRcExists: true, + maxUnavail: intstr.FromInt(0), + maxSurge: intstr.FromInt(1), + expected: []interface{}{ + up{1}, + down{oldReady: 0, newReady: 1, to: 0}, + }, + output: `Continuing update with existing controller foo-v2. +Scaling up foo-v2 from 1 to 1, scaling down foo-v1 from 1 to 0 (keep 1 pods available, don't exceed 2 pods) +Scaling foo-v1 down to 0 +`, + }, + { + name: "3->0 1/1 desired 0 (absolute values)", + oldRc: oldRc(3, 3), + newRc: newRc(0, 0), + newRcExists: true, + maxUnavail: intstr.FromInt(1), + maxSurge: intstr.FromInt(1), + expected: []interface{}{ + down{oldReady: 3, newReady: 0, to: 0}, + }, + output: `Continuing update with existing controller foo-v2. +Scaling up foo-v2 from 0 to 0, scaling down foo-v1 from 3 to 0 (keep 0 pods available, don't exceed 1 pods) +Scaling foo-v1 down to 0 +`, + }, + { + name: "3->0 10/10 desired 0 (percentages)", + oldRc: oldRc(3, 3), + newRc: newRc(0, 0), + newRcExists: true, + maxUnavail: intstr.FromString("10%"), + maxSurge: intstr.FromString("10%"), + expected: []interface{}{ + down{oldReady: 3, newReady: 0, to: 0}, + }, + output: `Continuing update with existing controller foo-v2. +Scaling up foo-v2 from 0 to 0, scaling down foo-v1 from 3 to 0 (keep 0 pods available, don't exceed 0 pods) +Scaling foo-v1 down to 0 +`, + }, + { + name: "3->0 10/10 desired 0 (create new RC)", + oldRc: oldRc(3, 3), + newRc: newRc(0, 0), + newRcExists: false, + maxUnavail: intstr.FromString("10%"), + maxSurge: intstr.FromString("10%"), + expected: []interface{}{ + down{oldReady: 3, newReady: 0, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 0, scaling down foo-v1 from 3 to 0 (keep 0 pods available, don't exceed 0 pods) +Scaling foo-v1 down to 0 +`, + }, + { + name: "0->0 1/1 desired 0 (absolute values)", + oldRc: oldRc(0, 0), + newRc: newRc(0, 0), + newRcExists: true, + maxUnavail: intstr.FromInt(1), + maxSurge: intstr.FromInt(1), + expected: []interface{}{ + down{oldReady: 0, newReady: 0, to: 0}, + }, + output: `Continuing update with existing controller foo-v2. +Scaling up foo-v2 from 0 to 0, scaling down foo-v1 from 0 to 0 (keep 0 pods available, don't exceed 1 pods) +`, + }, { + name: "30->2 50%/0", + oldRc: oldRc(30, 30), + newRc: newRc(0, 2), + newRcExists: false, + maxUnavail: intstr.FromString("50%"), + maxSurge: intstr.FromInt(0), + expected: []interface{}{ + down{oldReady: 30, newReady: 0, to: 1}, + up{1}, + down{oldReady: 1, newReady: 2, to: 0}, + up{2}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 2, scaling down foo-v1 from 30 to 0 (keep 1 pods available, don't exceed 2 pods) +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 1 +Scaling foo-v1 down to 0 +Scaling foo-v2 up to 2 +`, + }, + { + name: "2->2 1/0 blocked oldRc", + oldRc: oldRc(2, 2), + newRc: newRc(0, 2), + newRcExists: false, + maxUnavail: intstr.FromInt(1), + maxSurge: intstr.FromInt(0), + expected: []interface{}{ + down{oldReady: 1, newReady: 0, to: 1}, + up{1}, + down{oldReady: 1, newReady: 1, to: 0}, + up{2}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 2, scaling down foo-v1 from 2 to 0 (keep 1 pods available, don't exceed 2 pods) +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 1 +Scaling foo-v1 down to 0 +Scaling foo-v2 up to 2 +`, + }, + { + name: "1->1 1/0 allow maxUnavailability", + oldRc: oldRc(1, 1), + newRc: newRc(0, 1), + newRcExists: false, + maxUnavail: intstr.FromString("1%"), + maxSurge: intstr.FromInt(0), + expected: []interface{}{ + down{oldReady: 1, newReady: 0, to: 0}, + up{1}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 1, scaling down foo-v1 from 1 to 0 (keep 0 pods available, don't exceed 1 pods) +Scaling foo-v1 down to 0 +Scaling foo-v2 up to 1 +`, + }, + { + name: "1->2 25/25 complex asymmetric deployment", + oldRc: oldRc(1, 1), + newRc: newRc(0, 2), + newRcExists: false, + maxUnavail: intstr.FromString("25%"), + maxSurge: intstr.FromString("25%"), + expected: []interface{}{ + up{2}, + down{oldReady: 1, newReady: 2, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 2, scaling down foo-v1 from 1 to 0 (keep 2 pods available, don't exceed 3 pods) +Scaling foo-v2 up to 2 +Scaling foo-v1 down to 0 +`, + }, + { + name: "2->2 25/1 maxSurge trumps maxUnavailable", + oldRc: oldRc(2, 2), + newRc: newRc(0, 2), + newRcExists: false, + maxUnavail: intstr.FromString("25%"), + maxSurge: intstr.FromString("1%"), + expected: []interface{}{ + up{1}, + down{oldReady: 2, newReady: 1, to: 1}, + up{2}, + down{oldReady: 1, newReady: 2, to: 0}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 2, scaling down foo-v1 from 2 to 0 (keep 2 pods available, don't exceed 3 pods) +Scaling foo-v2 up to 1 +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 2 +Scaling foo-v1 down to 0 +`, + }, + { + name: "2->2 25/0 maxUnavailable resolves to zero, then one", + oldRc: oldRc(2, 2), + newRc: newRc(0, 2), + newRcExists: false, + maxUnavail: intstr.FromString("25%"), + maxSurge: intstr.FromString("0%"), + expected: []interface{}{ + down{oldReady: 2, newReady: 0, to: 1}, + up{1}, + down{oldReady: 1, newReady: 1, to: 0}, + up{2}, + }, + output: `Created foo-v2 +Scaling up foo-v2 from 0 to 2, scaling down foo-v1 from 2 to 0 (keep 1 pods available, don't exceed 2 pods) +Scaling foo-v1 down to 1 +Scaling foo-v2 up to 1 +Scaling foo-v1 down to 0 +Scaling foo-v2 up to 2 +`, + }, + } + + for i, tt := range tests { + // Extract expectations into some makeshift FIFOs so they can be returned + // in the correct order from the right places. This lets scale downs be + // expressed a single event even though the data is used from multiple + // interface calls. + t.Run(tt.name, func(t *testing.T) { + oldReady := []int{} + newReady := []int{} + upTo := []int{} + downTo := []int{} + for _, event := range tt.expected { + switch e := event.(type) { + case down: + oldReady = append(oldReady, e.oldReady) + newReady = append(newReady, e.newReady) + if !e.noop { + downTo = append(downTo, e.to) + } + case up: + upTo = append(upTo, e.to) + } + } + + // Make a way to get the next item from our FIFOs. Returns -1 if the array + // is empty. + next := func(s *[]int) int { + slice := *s + v := -1 + if len(slice) > 0 { + v = slice[0] + if len(slice) > 1 { + *s = slice[1:] + } else { + *s = []int{} + } + } + return v + } + t.Logf("running test %d (%s) (up: %v, down: %v, oldReady: %v, newReady: %v)", i, tt.name, upTo, downTo, oldReady, newReady) + updater := &RollingUpdater{ + ns: "default", + scaleAndWait: func(rc *corev1.ReplicationController, retry *scale.RetryParams, wait *scale.RetryParams) (*corev1.ReplicationController, error) { + // Return a scale up or scale down expectation depending on the rc, + // and throw errors if there is no expectation expressed for this + // call. + expected := -1 + switch { + case rc == tt.newRc: + t.Logf("scaling up %s to %d", rc.Name, rc.Spec.Replicas) + expected = next(&upTo) + case rc == tt.oldRc: + t.Logf("scaling down %s to %d", rc.Name, rc.Spec.Replicas) + expected = next(&downTo) + } + if expected == -1 { + t.Fatalf("unexpected scale of %s to %d", rc.Name, rc.Spec.Replicas) + } else if e, a := expected, int(*rc.Spec.Replicas); e != a { + t.Fatalf("expected scale of %s to %d, got %d", rc.Name, e, a) + } + // Simulate the scale. + rc.Status.Replicas = *rc.Spec.Replicas + return rc, nil + }, + getOrCreateTargetController: func(controller *corev1.ReplicationController, sourceID string) (*corev1.ReplicationController, bool, error) { + // Simulate a create vs. update of an existing controller. + return tt.newRc, tt.newRcExists, nil + }, + cleanup: func(oldRc, newRc *corev1.ReplicationController, config *RollingUpdaterConfig) error { + return nil + }, + } + // Set up a mock readiness check which handles the test assertions. + updater.getReadyPods = func(oldRc, newRc *corev1.ReplicationController, minReadySecondsDeadline int32) (int32, int32, error) { + // Return simulated readiness, and throw an error if this call has no + // expectations defined. + oldReady := next(&oldReady) + newReady := next(&newReady) + if oldReady == -1 || newReady == -1 { + t.Fatalf("unexpected getReadyPods call for:\noldRc: %#v\nnewRc: %#v", oldRc, newRc) + } + return int32(oldReady), int32(newReady), nil + } + var buffer bytes.Buffer + config := &RollingUpdaterConfig{ + Out: &buffer, + OldRc: tt.oldRc, + NewRc: tt.newRc, + UpdatePeriod: 0, + Interval: time.Millisecond, + Timeout: time.Millisecond, + CleanupPolicy: DeleteRollingUpdateCleanupPolicy, + MaxUnavailable: tt.maxUnavail, + MaxSurge: tt.maxSurge, + } + err := updater.Update(config) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if buffer.String() != tt.output { + t.Errorf("Bad output. expected:\n%s\ngot:\n%s", tt.output, buffer.String()) + } + }) + } +} + +// TestUpdate_progressTimeout ensures that an update which isn't making any +// progress will eventually time out with a specified error. +func TestUpdate_progressTimeout(t *testing.T) { + oldRc := oldRc(2, 2) + newRc := newRc(0, 2) + updater := &RollingUpdater{ + ns: "default", + scaleAndWait: func(rc *corev1.ReplicationController, retry *scale.RetryParams, wait *scale.RetryParams) (*corev1.ReplicationController, error) { + // Do nothing. + return rc, nil + }, + getOrCreateTargetController: func(controller *corev1.ReplicationController, sourceID string) (*corev1.ReplicationController, bool, error) { + return newRc, false, nil + }, + cleanup: func(oldRc, newRc *corev1.ReplicationController, config *RollingUpdaterConfig) error { + return nil + }, + } + updater.getReadyPods = func(oldRc, newRc *corev1.ReplicationController, minReadySeconds int32) (int32, int32, error) { + // Coerce a timeout by pods never becoming ready. + return 0, 0, nil + } + var buffer bytes.Buffer + config := &RollingUpdaterConfig{ + Out: &buffer, + OldRc: oldRc, + NewRc: newRc, + UpdatePeriod: 0, + Interval: time.Millisecond, + Timeout: time.Millisecond, + CleanupPolicy: DeleteRollingUpdateCleanupPolicy, + MaxUnavailable: intstr.FromInt(0), + MaxSurge: intstr.FromInt(1), + } + err := updater.Update(config) + if err == nil { + t.Fatalf("expected an error") + } + if e, a := "timed out waiting for any update progress to be made", err.Error(); e != a { + t.Fatalf("expected error message: %s, got: %s", e, a) + } +} + +func TestUpdate_assignOriginalAnnotation(t *testing.T) { + oldRc := oldRc(1, 1) + delete(oldRc.Annotations, originalReplicasAnnotation) + newRc := newRc(1, 1) + fake := fake.NewSimpleClientset(oldRc) + updater := &RollingUpdater{ + rcClient: fake.CoreV1(), + podClient: fake.CoreV1(), + ns: "default", + scaleAndWait: func(rc *corev1.ReplicationController, retry *scale.RetryParams, wait *scale.RetryParams) (*corev1.ReplicationController, error) { + return rc, nil + }, + getOrCreateTargetController: func(controller *corev1.ReplicationController, sourceID string) (*corev1.ReplicationController, bool, error) { + return newRc, false, nil + }, + cleanup: func(oldRc, newRc *corev1.ReplicationController, config *RollingUpdaterConfig) error { + return nil + }, + getReadyPods: func(oldRc, newRc *corev1.ReplicationController, minReadySeconds int32) (int32, int32, error) { + return 1, 1, nil + }, + } + var buffer bytes.Buffer + config := &RollingUpdaterConfig{ + Out: &buffer, + OldRc: oldRc, + NewRc: newRc, + UpdatePeriod: 0, + Interval: time.Millisecond, + Timeout: time.Millisecond, + CleanupPolicy: DeleteRollingUpdateCleanupPolicy, + MaxUnavailable: intstr.FromString("100%"), + } + err := updater.Update(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + updateAction := fake.Actions()[1].(testcore.UpdateAction) + if updateAction.GetResource().GroupResource() != corev1.Resource("replicationcontrollers") { + t.Fatalf("expected rc to be updated: %#v", updateAction) + } + if e, a := "1", updateAction.GetObject().(*corev1.ReplicationController).Annotations[originalReplicasAnnotation]; e != a { + t.Fatalf("expected annotation value %s, got %s", e, a) + } +} + +func TestRollingUpdater_multipleContainersInPod(t *testing.T) { + tests := []struct { + name string + oldRc *corev1.ReplicationController + newRc *corev1.ReplicationController + container string + image string + deploymentKey string + }{ + { + name: "test1", + oldRc: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "foo", + }, + Spec: corev1.ReplicationControllerSpec{ + Selector: map[string]string{ + "dk": "old", + }, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "dk": "old", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container1", + Image: "image1", + }, + { + Name: "container2", + Image: "image2", + }, + }, + }, + }, + }, + }, + newRc: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "foo", + }, + Spec: corev1.ReplicationControllerSpec{ + Selector: map[string]string{ + "dk": "old", + }, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "dk": "old", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container1", + Image: "newimage", + }, + { + Name: "container2", + Image: "image2", + }, + }, + }, + }, + }, + }, + container: "container1", + image: "newimage", + deploymentKey: "dk", + }, + { + name: "test2", + oldRc: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "bar", + }, + Spec: corev1.ReplicationControllerSpec{ + Selector: map[string]string{ + "dk": "old", + }, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "dk": "old", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container1", + Image: "image1", + }, + }, + }, + }, + }, + }, + newRc: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "bar", + }, + Spec: corev1.ReplicationControllerSpec{ + Selector: map[string]string{ + "dk": "old", + }, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "dk": "old", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container1", + Image: "newimage", + }, + }, + }, + }, + }, + }, + container: "container1", + image: "newimage", + deploymentKey: "dk", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := fake.NewSimpleClientset(tt.oldRc) + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + + deploymentHash, err := util.HashObject(tt.newRc, codec) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + tt.newRc.Spec.Selector[tt.deploymentKey] = deploymentHash + tt.newRc.Spec.Template.Labels[tt.deploymentKey] = deploymentHash + tt.newRc.Name = fmt.Sprintf("%s-%s", tt.newRc.Name, deploymentHash) + + config := &NewControllerConfig{ + Namespace: metav1.NamespaceDefault, + OldName: tt.oldRc.ObjectMeta.Name, + NewName: tt.newRc.ObjectMeta.Name, + Image: tt.image, + Container: tt.container, + DeploymentKey: tt.deploymentKey, + } + updatedRc, err := CreateNewControllerFromCurrentController(fake.CoreV1(), codec, config) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !reflect.DeepEqual(updatedRc, tt.newRc) { + t.Errorf("expected:\n%#v\ngot:\n%#v\n", tt.newRc, updatedRc) + } + }) + } +} + +// TestRollingUpdater_cleanupWithClients ensures that the cleanup policy is +// correctly implemented. +func TestRollingUpdater_cleanupWithClients(t *testing.T) { + rc := oldRc(2, 2) + rcExisting := newRc(1, 3) + + tests := []struct { + name string + policy RollingUpdaterCleanupPolicy + responses []runtime.Object + expected []string + }{ + { + name: "preserve", + policy: PreserveRollingUpdateCleanupPolicy, + responses: []runtime.Object{rcExisting}, + expected: []string{ + "get", + "update", + "get", + "get", + }, + }, + { + name: "delete", + policy: DeleteRollingUpdateCleanupPolicy, + responses: []runtime.Object{rcExisting}, + expected: []string{ + "get", + "update", + "get", + "get", + "delete", + }, + }, + //{ + // This cases is separated to a standalone + // TestRollingUpdater_cleanupWithClients_Rename. We have to do this + // because the unversioned fake client is unable to delete objects. + // TODO: uncomment this case when the unversioned fake client uses + // pkg/client/testing/core. + // { + // name: "rename", + // policy: RenameRollingUpdateCleanupPolicy, + // responses: []runtime.Object{rcExisting}, + // expected: []string{ + // "get", + // "update", + // "get", + // "get", + // "delete", + // "create", + // "delete", + // }, + // }, + //}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objs := []runtime.Object{rc} + objs = append(objs, tt.responses...) + fake := fake.NewSimpleClientset(objs...) + updater := &RollingUpdater{ + ns: "default", + rcClient: fake.CoreV1(), + podClient: fake.CoreV1(), + } + config := &RollingUpdaterConfig{ + Out: ioutil.Discard, + OldRc: rc, + NewRc: rcExisting, + UpdatePeriod: 0, + Interval: time.Millisecond, + Timeout: time.Millisecond, + CleanupPolicy: tt.policy, + } + err := updater.cleanupWithClients(rc, rcExisting, config) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(fake.Actions()) != len(tt.expected) { + t.Fatalf("%s: unexpected actions: %v, expected %v", tt.name, fake.Actions(), tt.expected) + } + for j, action := range fake.Actions() { + if e, a := tt.expected[j], action.GetVerb(); e != a { + t.Errorf("%s: unexpected action: expected %s, got %s", tt.name, e, a) + } + } + }) + } +} + +// TestRollingUpdater_cleanupWithClients_Rename tests the rename cleanup policy. It's separated to +// a standalone test because the unversioned fake client is unable to delete +// objects. +// TODO: move this test back to TestRollingUpdater_cleanupWithClients +// when the fake client uses pkg/client/testing/core in the future. +func TestRollingUpdater_cleanupWithClients_Rename(t *testing.T) { + rc := oldRc(2, 2) + rcExisting := newRc(1, 3) + expectedActions := []string{"delete", "get", "create"} + fake := fake.NewSimpleClientset() + fake.AddReactor("*", "*", func(action testcore.Action) (handled bool, ret runtime.Object, err error) { + switch action.(type) { + case testcore.CreateAction: + return true, nil, nil + case testcore.GetAction: + return true, nil, errors.NewNotFound(schema.GroupResource{}, "") + case testcore.DeleteAction: + return true, nil, nil + } + return false, nil, nil + }) + + err := Rename(fake.CoreV1(), rcExisting, rc.Name) + if err != nil { + t.Fatal(err) + } + for j, action := range fake.Actions() { + if e, a := expectedActions[j], action.GetVerb(); e != a { + t.Errorf("unexpected action: expected %s, got %s", e, a) + } + } +} + +func TestFindSourceController(t *testing.T) { + ctrl1 := corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "foo", + Annotations: map[string]string{ + sourceIDAnnotation: "bar:1234", + }, + }, + } + ctrl2 := corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "bar", + Annotations: map[string]string{ + sourceIDAnnotation: "foo:12345", + }, + }, + } + ctrl3 := corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "baz", + Annotations: map[string]string{ + sourceIDAnnotation: "baz:45667", + }, + }, + } + tests := []struct { + list *corev1.ReplicationControllerList + expectedController *corev1.ReplicationController + name string + expectError bool + }{ + { + list: &corev1.ReplicationControllerList{}, + expectError: true, + }, + { + list: &corev1.ReplicationControllerList{ + Items: []corev1.ReplicationController{ctrl1}, + }, + name: "foo", + expectError: true, + }, + { + list: &corev1.ReplicationControllerList{ + Items: []corev1.ReplicationController{ctrl1}, + }, + name: "bar", + expectedController: &ctrl1, + }, + { + list: &corev1.ReplicationControllerList{ + Items: []corev1.ReplicationController{ctrl1, ctrl2}, + }, + name: "bar", + expectedController: &ctrl1, + }, + { + list: &corev1.ReplicationControllerList{ + Items: []corev1.ReplicationController{ctrl1, ctrl2}, + }, + name: "foo", + expectedController: &ctrl2, + }, + { + list: &corev1.ReplicationControllerList{ + Items: []corev1.ReplicationController{ctrl1, ctrl2, ctrl3}, + }, + name: "baz", + expectedController: &ctrl3, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(tt.list) + ctrl, err := FindSourceController(fakeClient.CoreV1(), "default", tt.name) + if tt.expectError && err == nil { + t.Errorf("unexpected non-error") + } + if !tt.expectError && err != nil { + t.Errorf("unexpected error") + } + if !reflect.DeepEqual(ctrl, tt.expectedController) { + t.Errorf("expected:\n%v\ngot:\n%v\n", tt.expectedController, ctrl) + } + }) + } +} + +func TestUpdateExistingReplicationController(t *testing.T) { + tests := []struct { + rc *corev1.ReplicationController + name string + deploymentKey string + deploymentValue string + + expectedRc *corev1.ReplicationController + expectErr bool + }{ + { + rc: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "foo", + }, + Spec: corev1.ReplicationControllerSpec{ + Template: &corev1.PodTemplateSpec{}, + }, + }, + name: "foo", + deploymentKey: "dk", + deploymentValue: "some-hash", + + expectedRc: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "foo", + Annotations: map[string]string{ + "kubectl.kubernetes.io/next-controller-id": "foo", + }, + }, + Spec: corev1.ReplicationControllerSpec{ + Selector: map[string]string{ + "dk": "some-hash", + }, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "dk": "some-hash", + }, + }, + }, + }, + }, + }, + { + rc: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "foo", + }, + Spec: corev1.ReplicationControllerSpec{ + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "dk": "some-other-hash", + }, + }, + }, + Selector: map[string]string{ + "dk": "some-other-hash", + }, + }, + }, + name: "foo", + deploymentKey: "dk", + deploymentValue: "some-hash", + + expectedRc: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "foo", + Annotations: map[string]string{ + "kubectl.kubernetes.io/next-controller-id": "foo", + }, + }, + Spec: corev1.ReplicationControllerSpec{ + Selector: map[string]string{ + "dk": "some-other-hash", + }, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "dk": "some-other-hash", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buffer := &bytes.Buffer{} + fakeClient := fake.NewSimpleClientset(tt.expectedRc) + rc, err := UpdateExistingReplicationController(fakeClient.CoreV1(), fakeClient.CoreV1(), tt.rc, "default", tt.name, tt.deploymentKey, tt.deploymentValue, buffer) + if !reflect.DeepEqual(rc, tt.expectedRc) { + t.Errorf("expected:\n%#v\ngot:\n%#v\n", tt.expectedRc, rc) + } + if tt.expectErr && err == nil { + t.Errorf("unexpected non-error") + } + if !tt.expectErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestUpdateRcWithRetries(t *testing.T) { + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + one := int32(1) + grace := int64(30) + enableServiceLinks := corev1.DefaultEnableServiceLinks + rc := &corev1.ReplicationController{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "ReplicationController", + }, + ObjectMeta: metav1.ObjectMeta{Name: "rc", + Labels: map[string]string{ + "foo": "bar", + }, + }, + Spec: corev1.ReplicationControllerSpec{ + Replicas: &one, + Selector: map[string]string{ + "foo": "bar", + }, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "foo": "bar", + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + DNSPolicy: corev1.DNSClusterFirst, + TerminationGracePeriodSeconds: &grace, + SecurityContext: &corev1.PodSecurityContext{}, + EnableServiceLinks: &enableServiceLinks, + }, + }, + }, + } + rc.Spec.Template.Spec.SchedulerName = "default-scheduler" + + // Test end to end updating of the rc with retries. Essentially make sure the update handler + // sees the right updates, failures in update/get are handled properly, and that the updated + // rc with new resource version is returned to the caller. Without any of these rollingupdate + // will fail cryptically. + newRc := *rc + newRc.ResourceVersion = "2" + newRc.Spec.Selector["baz"] = "foobar" + header := http.Header{} + header.Set("Content-Type", runtime.ContentTypeJSON) + updates := []*http.Response{ + {StatusCode: 409, Header: header, Body: objBody(codec, &corev1.ReplicationController{})}, // conflict + {StatusCode: 409, Header: header, Body: objBody(codec, &corev1.ReplicationController{})}, // conflict + {StatusCode: 200, Header: header, Body: objBody(codec, &newRc)}, + } + gets := []*http.Response{ + {StatusCode: 500, Header: header, Body: objBody(codec, &corev1.ReplicationController{})}, + {StatusCode: 200, Header: header, Body: objBody(codec, rc)}, + } + fakeClient := &manualfake.RESTClient{ + GroupVersion: corev1.SchemeGroupVersion, + NegotiatedSerializer: scheme.Codecs, + Client: manualfake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api/v1/namespaces/default/replicationcontrollers/rc" && m == "PUT": + update := updates[0] + updates = updates[1:] + // We should always get an update with a valid rc even when the get fails. The rc should always + // contain the update. + if c, ok := readOrDie(t, req, codec).(*corev1.ReplicationController); !ok || !apiequality.Semantic.DeepEqual(rc, c) { + t.Errorf("Unexpected update body, got %+v expected %+v", c, rc) + t.Error(diff.ObjectDiff(rc, c)) + } else if sel, ok := c.Spec.Selector["baz"]; !ok || sel != "foobar" { + t.Errorf("Expected selector label update, got %+v", c.Spec.Selector) + } else { + delete(c.Spec.Selector, "baz") + } + return update, nil + case p == "/api/v1/namespaces/default/replicationcontrollers/rc" && m == "GET": + get := gets[0] + gets = gets[1:] + return get, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + clientConfig := &restclient.Config{ + APIPath: "/api", + ContentConfig: restclient.ContentConfig{ + NegotiatedSerializer: scheme.Codecs, + GroupVersion: &corev1.SchemeGroupVersion, + }, + } + restClient, _ := restclient.RESTClientFor(clientConfig) + restClient.Client = fakeClient.Client + clientset := kubernetes.New(restClient) + + if rc, err := updateRcWithRetries( + clientset.CoreV1(), "default", rc, func(c *corev1.ReplicationController) { + c.Spec.Selector["baz"] = "foobar" + }); err != nil { + t.Errorf("unexpected error: %v", err) + } else if sel, ok := rc.Spec.Selector["baz"]; !ok || sel != "foobar" || rc.ResourceVersion != "2" { + t.Errorf("Expected updated rc, got %+v", rc) + } + if len(updates) != 0 || len(gets) != 0 { + t.Errorf("Remaining updates %#v gets %#v", updates, gets) + } +} + +func readOrDie(t *testing.T, req *http.Request, codec runtime.Codec) runtime.Object { + data, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Errorf("Error reading: %v", err) + t.FailNow() + } + codec2 := scheme.Codecs.UniversalDecoder(scheme.Scheme.PrioritizedVersionsAllGroups()...) + obj, err := runtime.Decode(codec2, data) + if err != nil { + t.Log(string(data)) + t.Errorf("error decoding: %v", err) + t.FailNow() + } + return obj +} + +func objBody(codec runtime.Codec, obj runtime.Object) io.ReadCloser { + return ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(codec, obj)))) +} + +func TestAddDeploymentHash(t *testing.T) { + buf := &bytes.Buffer{} + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + rc := &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{Name: "rc"}, + Spec: corev1.ReplicationControllerSpec{ + Selector: map[string]string{ + "foo": "bar", + }, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "foo": "bar", + }, + }, + }, + }, + } + + podList := &corev1.PodList{ + Items: []corev1.Pod{ + {ObjectMeta: metav1.ObjectMeta{Name: "foo"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "bar"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "baz"}}, + }, + } + + seen := sets.String{} + updatedRc := false + fakeClient := &manualfake.RESTClient{ + GroupVersion: corev1.SchemeGroupVersion, + NegotiatedSerializer: scheme.Codecs, + Client: manualfake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + header := http.Header{} + header.Set("Content-Type", runtime.ContentTypeJSON) + switch p, m := req.URL.Path, req.Method; { + case p == "/api/v1/namespaces/default/pods" && m == "GET": + if req.URL.RawQuery != "labelSelector=foo%3Dbar" { + t.Errorf("Unexpected query string: %s", req.URL.RawQuery) + } + return &http.Response{StatusCode: 200, Header: header, Body: objBody(codec, podList)}, nil + case p == "/api/v1/namespaces/default/pods/foo" && m == "PUT": + seen.Insert("foo") + obj := readOrDie(t, req, codec) + podList.Items[0] = *(obj.(*corev1.Pod)) + return &http.Response{StatusCode: 200, Header: header, Body: objBody(codec, &podList.Items[0])}, nil + case p == "/api/v1/namespaces/default/pods/bar" && m == "PUT": + seen.Insert("bar") + obj := readOrDie(t, req, codec) + podList.Items[1] = *(obj.(*corev1.Pod)) + return &http.Response{StatusCode: 200, Header: header, Body: objBody(codec, &podList.Items[1])}, nil + case p == "/api/v1/namespaces/default/pods/baz" && m == "PUT": + seen.Insert("baz") + obj := readOrDie(t, req, codec) + podList.Items[2] = *(obj.(*corev1.Pod)) + return &http.Response{StatusCode: 200, Header: header, Body: objBody(codec, &podList.Items[2])}, nil + case p == "/api/v1/namespaces/default/replicationcontrollers/rc" && m == "PUT": + updatedRc = true + return &http.Response{StatusCode: 200, Header: header, Body: objBody(codec, rc)}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + } + clientConfig := &restclient.Config{ + APIPath: "/api", + ContentConfig: restclient.ContentConfig{ + NegotiatedSerializer: scheme.Codecs, + GroupVersion: &corev1.SchemeGroupVersion, + }, + } + restClient, _ := restclient.RESTClientFor(clientConfig) + restClient.Client = fakeClient.Client + clientset := kubernetes.New(restClient) + + if _, err := AddDeploymentKeyToReplicationController(rc, clientset.CoreV1(), clientset.CoreV1(), "dk", "hash", metav1.NamespaceDefault, buf); err != nil { + t.Errorf("unexpected error: %v", err) + } + for _, pod := range podList.Items { + if !seen.Has(pod.Name) { + t.Errorf("Missing update for pod: %s", pod.Name) + } + } + if !updatedRc { + t.Errorf("Failed to update replication controller with new labels") + } +} + +func TestRollingUpdater_readyPods(t *testing.T) { + count := 0 + now := metav1.Date(2016, time.April, 1, 1, 0, 0, 0, time.UTC) + mkpod := func(owner *corev1.ReplicationController, ready bool, readyTime metav1.Time) *corev1.Pod { + count = count + 1 + labels := map[string]string{} + for k, v := range owner.Spec.Selector { + labels[k] = v + } + status := corev1.ConditionTrue + if !ready { + status = corev1.ConditionFalse + } + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: fmt.Sprintf("pod-%d", count), + Labels: labels, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: status, + LastTransitionTime: readyTime, + }, + }, + }, + } + } + + tests := []struct { + name string + oldRc *corev1.ReplicationController + newRc *corev1.ReplicationController + // expectated old/new ready counts + oldReady int32 + newReady int32 + // pods owned by the rcs; indicate whether they're ready + oldPods []bool + newPods []bool + // deletions - should be less then the size of the respective slice above + // e.g. len(oldPods) > oldPodDeletions && len(newPods) > newPodDeletions + oldPodDeletions int + newPodDeletions int + // specify additional time to wait for deployment to wait on top of the + // pod ready time + minReadySeconds int32 + podReadyTimeFn func() metav1.Time + nowFn func() metav1.Time + }{ + { + name: "test1", + oldRc: oldRc(4, 4), + newRc: newRc(4, 4), + oldReady: 4, + newReady: 2, + oldPods: []bool{ + true, + true, + true, + true, + }, + newPods: []bool{ + true, + false, + true, + false, + }, + }, + { + name: "test2", + oldRc: oldRc(4, 4), + newRc: newRc(4, 4), + oldReady: 0, + newReady: 1, + oldPods: []bool{ + false, + }, + newPods: []bool{ + true, + }, + }, + { + name: "test3", + oldRc: oldRc(4, 4), + newRc: newRc(4, 4), + oldReady: 1, + newReady: 0, + oldPods: []bool{ + true, + }, + newPods: []bool{ + false, + }, + }, + { + name: "test4", + oldRc: oldRc(4, 4), + newRc: newRc(4, 4), + oldReady: 0, + newReady: 0, + oldPods: []bool{ + true, + }, + newPods: []bool{ + true, + }, + minReadySeconds: 5, + nowFn: func() metav1.Time { return now }, + }, + { + name: "test5", + oldRc: oldRc(4, 4), + newRc: newRc(4, 4), + oldReady: 1, + newReady: 1, + oldPods: []bool{ + true, + }, + newPods: []bool{ + true, + }, + minReadySeconds: 5, + nowFn: func() metav1.Time { return metav1.Time{Time: now.Add(time.Duration(6 * time.Second))} }, + podReadyTimeFn: func() metav1.Time { return now }, + }, + { + name: "test6", + oldRc: oldRc(4, 4), + newRc: newRc(4, 4), + oldReady: 2, + newReady: 0, + oldPods: []bool{ + // All old pods are ready + true, true, true, true, + }, + // Two of them have been marked for deletion though + oldPodDeletions: 2, + }, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Logf("evaluating test %d", i) + if tt.nowFn == nil { + tt.nowFn = func() metav1.Time { return now } + } + if tt.podReadyTimeFn == nil { + tt.podReadyTimeFn = tt.nowFn + } + // Populate the fake client with pods associated with their owners. + pods := []runtime.Object{} + for _, ready := range tt.oldPods { + pod := mkpod(tt.oldRc, ready, tt.podReadyTimeFn()) + if tt.oldPodDeletions > 0 { + now := metav1.Now() + pod.DeletionTimestamp = &now + tt.oldPodDeletions-- + } + pods = append(pods, pod) + } + for _, ready := range tt.newPods { + pod := mkpod(tt.newRc, ready, tt.podReadyTimeFn()) + if tt.newPodDeletions > 0 { + now := metav1.Now() + pod.DeletionTimestamp = &now + tt.newPodDeletions-- + } + pods = append(pods, pod) + } + client := fake.NewSimpleClientset(pods...) + + updater := &RollingUpdater{ + ns: "default", + rcClient: client.CoreV1(), + podClient: client.CoreV1(), + nowFn: tt.nowFn, + } + oldReady, newReady, err := updater.readyPods(tt.oldRc, tt.newRc, tt.minReadySeconds) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if e, a := tt.oldReady, oldReady; e != a { + t.Errorf("expected old ready %d, got %d", e, a) + } + if e, a := tt.newReady, newReady; e != a { + t.Errorf("expected new ready %d, got %d", e, a) + } + }) + } +} diff --git a/pkg/cmd/rollingupdate/rollingupdate.go b/pkg/cmd/rollingupdate/rollingupdate.go new file mode 100644 index 000000000..d4adc6e9b --- /dev/null +++ b/pkg/cmd/rollingupdate/rollingupdate.go @@ -0,0 +1,468 @@ +/* +Copyright 2014 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 rollingupdate + +import ( + "bytes" + "fmt" + "time" + + "github.com/spf13/cobra" + "k8s.io/klog" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/kubernetes" + scaleclient "k8s.io/client-go/scale" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubectl/pkg/validation" +) + +var ( + rollingUpdateLong = templates.LongDesc(i18n.T(` + Perform a rolling update of the given ReplicationController. + + Replaces the specified replication controller with a new replication controller by updating one pod at a time to use the + new PodTemplate. The new-controller.json must specify the same namespace as the + existing replication controller and overwrite at least one (common) label in its replicaSelector. + + ![Workflow](http://kubernetes.io/images/docs/kubectl_rollingupdate.svg)`)) + + rollingUpdateExample = templates.Examples(i18n.T(` + # Update pods of frontend-v1 using new replication controller data in frontend-v2.json. + kubectl rolling-update frontend-v1 -f frontend-v2.json + + # Update pods of frontend-v1 using JSON data passed into stdin. + cat frontend-v2.json | kubectl rolling-update frontend-v1 -f - + + # Update the pods of frontend-v1 to frontend-v2 by just changing the image, and switching the + # name of the replication controller. + kubectl rolling-update frontend-v1 frontend-v2 --image=image:v2 + + # Update the pods of frontend by just changing the image, and keeping the old name. + kubectl rolling-update frontend --image=image:v2 + + # Abort and reverse an existing rollout in progress (from frontend-v1 to frontend-v2). + kubectl rolling-update frontend-v1 frontend-v2 --rollback`)) +) + +const ( + updatePeriod = 1 * time.Minute + timeout = 5 * time.Minute + pollInterval = 3 * time.Second +) + +type RollingUpdateOptions struct { + FilenameOptions *resource.FilenameOptions + + OldName string + KeepOldName bool + + DeploymentKey string + Image string + Container string + PullPolicy string + Rollback bool + Period time.Duration + Timeout time.Duration + Interval time.Duration + DryRun bool + OutputFormat string + Namespace string + EnforceNamespace bool + + ScaleClient scaleclient.ScalesGetter + ClientSet kubernetes.Interface + Builder *resource.Builder + + ShouldValidate bool + Validator func(bool) (validation.Schema, error) + + FindNewName func(*corev1.ReplicationController) string + + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + genericclioptions.IOStreams +} + +func NewRollingUpdateOptions(streams genericclioptions.IOStreams) *RollingUpdateOptions { + return &RollingUpdateOptions{ + PrintFlags: genericclioptions.NewPrintFlags("rolling updated").WithTypeSetter(scheme.Scheme), + FilenameOptions: &resource.FilenameOptions{}, + DeploymentKey: "deployment", + Timeout: timeout, + Interval: pollInterval, + Period: updatePeriod, + + IOStreams: streams, + } +} + +func NewCmdRollingUpdate(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewRollingUpdateOptions(ioStreams) + + cmd := &cobra.Command{ + Use: "rolling-update OLD_CONTROLLER_NAME ([NEW_CONTROLLER_NAME] --image=NEW_CONTAINER_IMAGE | -f NEW_CONTROLLER_SPEC)", + DisableFlagsInUseLine: true, + Short: "Perform a rolling update. This command is deprecated, use rollout instead.", + Long: rollingUpdateLong, + Example: rollingUpdateExample, + Deprecated: `use "rollout" instead`, + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate(cmd, args)) + cmdutil.CheckErr(o.Run()) + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().DurationVar(&o.Period, "update-period", o.Period, `Time to wait between updating pods. Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`) + cmd.Flags().DurationVar(&o.Interval, "poll-interval", o.Interval, `Time delay between polling for replication controller status after the update. Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`) + cmd.Flags().DurationVar(&o.Timeout, "timeout", o.Timeout, `Max time to wait for a replication controller to update before giving up. Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`) + usage := "Filename or URL to file to use to create the new replication controller." + cmdutil.AddJsonFilenameFlag(cmd.Flags(), &o.FilenameOptions.Filenames, usage) + cmd.Flags().StringVar(&o.Image, "image", o.Image, i18n.T("Image to use for upgrading the replication controller. Must be distinct from the existing image (either new image or new image tag). Can not be used with --filename/-f")) + cmd.Flags().StringVar(&o.DeploymentKey, "deployment-label-key", o.DeploymentKey, i18n.T("The key to use to differentiate between two different controllers, default 'deployment'. Only relevant when --image is specified, ignored otherwise")) + cmd.Flags().StringVar(&o.Container, "container", o.Container, i18n.T("Container name which will have its image upgraded. Only relevant when --image is specified, ignored otherwise. Required when using --image on a multi-container pod")) + cmd.Flags().StringVar(&o.PullPolicy, "image-pull-policy", o.PullPolicy, i18n.T("Explicit policy for when to pull container images. Required when --image is same as existing image, ignored otherwise.")) + cmd.Flags().BoolVar(&o.Rollback, "rollback", o.Rollback, "If true, this is a request to abort an existing rollout that is partially rolled out. It effectively reverses current and next and runs a rollout") + cmdutil.AddDryRunFlag(cmd) + cmdutil.AddValidateFlags(cmd) + + return cmd +} + +func validateArguments(cmd *cobra.Command, filenames, args []string) error { + deploymentKey := cmdutil.GetFlagString(cmd, "deployment-label-key") + image := cmdutil.GetFlagString(cmd, "image") + rollback := cmdutil.GetFlagBool(cmd, "rollback") + + errors := []error{} + if len(deploymentKey) == 0 { + errors = append(errors, cmdutil.UsageErrorf(cmd, "--deployment-label-key can not be empty")) + } + if len(filenames) > 1 { + errors = append(errors, cmdutil.UsageErrorf(cmd, "May only specify a single filename for new controller")) + } + + if !rollback { + if len(filenames) == 0 && len(image) == 0 { + errors = append(errors, cmdutil.UsageErrorf(cmd, "Must specify --filename or --image for new controller")) + } else if len(filenames) != 0 && len(image) != 0 { + errors = append(errors, cmdutil.UsageErrorf(cmd, "--filename and --image can not both be specified")) + } + } else { + if len(filenames) != 0 || len(image) != 0 { + errors = append(errors, cmdutil.UsageErrorf(cmd, "Don't specify --filename or --image on rollback")) + } + } + + if len(args) < 1 { + errors = append(errors, cmdutil.UsageErrorf(cmd, "Must specify the controller to update")) + } + + return utilerrors.NewAggregate(errors) +} + +func (o *RollingUpdateOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + if len(args) > 0 { + o.OldName = args[0] + } + o.DryRun = cmdutil.GetDryRunFlag(cmd) + o.OutputFormat = cmdutil.GetFlagString(cmd, "output") + o.KeepOldName = len(args) == 1 + o.ShouldValidate = cmdutil.GetFlagBool(cmd, "validate") + + o.Validator = f.Validator + o.FindNewName = func(obj *corev1.ReplicationController) string { + return findNewName(args, obj) + } + + var err error + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.ScaleClient, err = cmdutil.ScaleClientFn(f) + if err != nil { + return err + } + + o.ClientSet, err = f.KubernetesClientSet() + if err != nil { + return err + } + + o.Builder = f.NewBuilder() + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + + return o.PrintFlags.ToPrinter() + } + return nil +} + +func (o *RollingUpdateOptions) Validate(cmd *cobra.Command, args []string) error { + return validateArguments(cmd, o.FilenameOptions.Filenames, args) +} + +func (o *RollingUpdateOptions) Run() error { + filename := "" + if len(o.FilenameOptions.Filenames) > 0 { + filename = o.FilenameOptions.Filenames[0] + } + + coreClient := o.ClientSet.CoreV1() + + var newRc *corev1.ReplicationController + // fetch rc + oldRc, err := coreClient.ReplicationControllers(o.Namespace).Get(o.OldName, metav1.GetOptions{}) + if err != nil { + if !errors.IsNotFound(err) || len(o.Image) == 0 || !o.KeepOldName { + return err + } + // We're in the middle of a rename, look for an RC with a source annotation of oldName + newRc, err := FindSourceController(coreClient, o.Namespace, o.OldName) + if err != nil { + return err + } + return Rename(coreClient, newRc, o.OldName) + } + + var replicasDefaulted bool + + if len(filename) != 0 { + schema, err := o.Validator(o.ShouldValidate) + if err != nil { + return err + } + + request := o.Builder. + Unstructured(). + Schema(schema). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &resource.FilenameOptions{Recursive: false, Filenames: []string{filename}}). + Flatten(). + Do() + infos, err := request.Infos() + if err != nil { + return err + } + // Handle filename input from stdin. + if len(infos) > 1 { + return fmt.Errorf("%s specifies multiple items", filename) + } + if len(infos) == 0 { + return fmt.Errorf("please make sure %s exists and is not empty", filename) + } + + uncastVersionedObj, err := scheme.Scheme.ConvertToVersion(infos[0].Object, corev1.SchemeGroupVersion) + if err != nil { + klog.V(4).Infof("Object %T is not a ReplicationController", infos[0].Object) + return fmt.Errorf("%s contains a %v not a ReplicationController", filename, infos[0].Object.GetObjectKind().GroupVersionKind()) + } + switch t := uncastVersionedObj.(type) { + case *corev1.ReplicationController: + replicasDefaulted = t.Spec.Replicas == nil + newRc = t + } + if newRc == nil { + klog.V(4).Infof("Object %T is not a ReplicationController", infos[0].Object) + return fmt.Errorf("%s contains a %v not a ReplicationController", filename, infos[0].Object.GetObjectKind().GroupVersionKind()) + } + } + + // If the --image option is specified, we need to create a new rc with at least one different selector + // than the old rc. This selector is the hash of the rc, with a suffix to provide uniqueness for + // same-image updates. + if len(o.Image) != 0 { + codec := scheme.Codecs.LegacyCodec(corev1.SchemeGroupVersion) + newName := o.FindNewName(oldRc) + if newRc, err = LoadExistingNextReplicationController(coreClient, o.Namespace, newName); err != nil { + return err + } + if newRc != nil { + if inProgressImage := newRc.Spec.Template.Spec.Containers[0].Image; inProgressImage != o.Image { + return fmt.Errorf("Found existing in-progress update to image (%s).\nEither continue in-progress update with --image=%s or rollback with --rollback", inProgressImage, inProgressImage) + } + fmt.Fprintf(o.Out, "Found existing update in progress (%s), resuming.\n", newRc.Name) + } else { + config := &NewControllerConfig{ + Namespace: o.Namespace, + OldName: o.OldName, + NewName: newName, + Image: o.Image, + Container: o.Container, + DeploymentKey: o.DeploymentKey, + } + if oldRc.Spec.Template.Spec.Containers[0].Image == o.Image { + if len(o.PullPolicy) == 0 { + return fmt.Errorf("--image-pull-policy (Always|Never|IfNotPresent) must be provided when --image is the same as existing container image") + } + config.PullPolicy = corev1.PullPolicy(o.PullPolicy) + } + newRc, err = CreateNewControllerFromCurrentController(coreClient, codec, config) + if err != nil { + return err + } + } + // Update the existing replication controller with pointers to the 'next' controller + // and adding the label if necessary to distinguish it from the 'next' controller. + oldHash, err := util.HashObject(oldRc, codec) + if err != nil { + return err + } + // If new image is same as old, the hash may not be distinct, so add a suffix. + oldHash += "-orig" + oldRc, err = UpdateExistingReplicationController(coreClient, coreClient, oldRc, o.Namespace, newRc.Name, o.DeploymentKey, oldHash, o.Out) + if err != nil { + return err + } + } + + if o.Rollback { + newName := o.FindNewName(oldRc) + if newRc, err = LoadExistingNextReplicationController(coreClient, o.Namespace, newName); err != nil { + return err + } + + if newRc == nil { + return fmt.Errorf("Could not find %s to rollback.\n", newName) + } + } + + if o.OldName == newRc.Name { + return fmt.Errorf("%s cannot have the same name as the existing ReplicationController %s", + filename, o.OldName) + } + + updater := NewRollingUpdater(newRc.Namespace, coreClient, coreClient, o.ScaleClient) + + // To successfully pull off a rolling update the new and old rc have to differ + // by at least one selector. Every new pod should have the selector and every + // old pod should not have the selector. + var hasLabel bool + for key, oldValue := range oldRc.Spec.Selector { + if newValue, ok := newRc.Spec.Selector[key]; ok && newValue != oldValue { + hasLabel = true + break + } + } + if !hasLabel { + return fmt.Errorf("%s must specify a matching key with non-equal value in Selector for %s", + filename, o.OldName) + } + // TODO: handle scales during rolling update + if replicasDefaulted { + t := *oldRc.Spec.Replicas + newRc.Spec.Replicas = &t + } + + if o.DryRun { + oldRcData := &bytes.Buffer{} + newRcData := &bytes.Buffer{} + if o.OutputFormat == "" { + oldRcData.WriteString(oldRc.Name) + newRcData.WriteString(newRc.Name) + } else { + printer, err := o.ToPrinter("rolling updated") + if err != nil { + return err + } + if err := printer.PrintObj(oldRc, oldRcData); err != nil { + return err + } + if err := printer.PrintObj(newRc, newRcData); err != nil { + return err + } + } + fmt.Fprintf(o.Out, "Rolling from:\n%s\nTo:\n%s\n", string(oldRcData.Bytes()), string(newRcData.Bytes())) + return nil + } + updateCleanupPolicy := DeleteRollingUpdateCleanupPolicy + if o.KeepOldName { + updateCleanupPolicy = RenameRollingUpdateCleanupPolicy + } + config := &RollingUpdaterConfig{ + Out: o.Out, + OldRc: oldRc, + NewRc: newRc, + UpdatePeriod: o.Period, + Interval: o.Interval, + Timeout: timeout, + CleanupPolicy: updateCleanupPolicy, + MaxUnavailable: intstr.FromInt(0), + MaxSurge: intstr.FromInt(1), + } + if o.Rollback { + err = AbortRollingUpdate(config) + if err != nil { + return err + } + coreClient.ReplicationControllers(config.NewRc.Namespace).Update(config.NewRc) + } + err = updater.Update(config) + if err != nil { + return err + } + + message := "rolling updated" + if o.KeepOldName { + newRc.Name = o.OldName + } else { + message = fmt.Sprintf("rolling updated to %q", newRc.Name) + } + newRc, err = coreClient.ReplicationControllers(o.Namespace).Get(newRc.Name, metav1.GetOptions{}) + if err != nil { + return err + } + + printer, err := o.ToPrinter(message) + if err != nil { + return err + } + return printer.PrintObj(newRc, o.Out) +} + +func findNewName(args []string, oldRc *corev1.ReplicationController) string { + if len(args) >= 2 { + return args[1] + } + if oldRc != nil { + newName, _ := GetNextControllerAnnotation(oldRc) + return newName + } + return "" +} diff --git a/pkg/cmd/rollingupdate/rollingupdate_test.go b/pkg/cmd/rollingupdate/rollingupdate_test.go new file mode 100644 index 000000000..d063c6cbd --- /dev/null +++ b/pkg/cmd/rollingupdate/rollingupdate_test.go @@ -0,0 +1,92 @@ +/* +Copyright 2014 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 rollingupdate + +import ( + "testing" + + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestValidateArgs(t *testing.T) { + f := cmdtesting.NewTestFactory() + defer f.Cleanup() + + tests := []struct { + testName string + flags map[string]string + filenames []string + args []string + expectErr bool + }{ + { + testName: "nothing", + expectErr: true, + }, + { + testName: "no file, no image", + flags: map[string]string{}, + args: []string{"foo"}, + expectErr: true, + }, + { + testName: "valid file example", + filenames: []string{"bar.yaml"}, + args: []string{"foo"}, + }, + { + testName: "missing second image name", + flags: map[string]string{ + "image": "foo:v2", + }, + args: []string{"foo"}, + }, + { + testName: "valid image example", + flags: map[string]string{ + "image": "foo:v2", + }, + args: []string{"foo", "foo-v2"}, + }, + { + testName: "both filename and image example", + flags: map[string]string{ + "image": "foo:v2", + }, + filenames: []string{"bar.yaml"}, + args: []string{"foo", "foo-v2"}, + expectErr: true, + }, + } + for _, test := range tests { + cmd := NewCmdRollingUpdate(f, genericclioptions.NewTestIOStreamsDiscard()) + + if test.flags != nil { + for key, val := range test.flags { + cmd.Flags().Set(key, val) + } + } + err := validateArguments(cmd, test.filenames, test.args) + if err != nil && !test.expectErr { + t.Errorf("%s: unexpected error: %v", test.testName, err) + } + if err == nil && test.expectErr { + t.Errorf("%s: unexpected non-error", test.testName) + } + } +} diff --git a/pkg/cmd/rollout/rollout.go b/pkg/cmd/rollout/rollout.go new file mode 100644 index 000000000..833c25eb7 --- /dev/null +++ b/pkg/cmd/rollout/rollout.go @@ -0,0 +1,67 @@ +/* +Copyright 2016 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 rollout + +import ( + "github.com/lithammer/dedent" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + rolloutLong = templates.LongDesc(` + Manage the rollout of a resource.` + rolloutValidResources) + + rolloutExample = templates.Examples(` + # Rollback to the previous deployment + kubectl rollout undo deployment/abc + + # Check the rollout status of a daemonset + kubectl rollout status daemonset/foo`) + + rolloutValidResources = dedent.Dedent(` + Valid resource types include: + + * deployments + * daemonsets + * statefulsets + `) +) + +// NewCmdRollout returns a Command instance for 'rollout' sub command +func NewCmdRollout(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "rollout SUBCOMMAND", + DisableFlagsInUseLine: true, + Short: i18n.T("Manage the rollout of a resource"), + Long: rolloutLong, + Example: rolloutExample, + Run: cmdutil.DefaultSubCommandRun(streams.Out), + } + // subcommands + cmd.AddCommand(NewCmdRolloutHistory(f, streams)) + cmd.AddCommand(NewCmdRolloutPause(f, streams)) + cmd.AddCommand(NewCmdRolloutResume(f, streams)) + cmd.AddCommand(NewCmdRolloutUndo(f, streams)) + cmd.AddCommand(NewCmdRolloutStatus(f, streams)) + cmd.AddCommand(NewCmdRolloutRestart(f, streams)) + + return cmd +} diff --git a/pkg/cmd/rollout/rollout_history.go b/pkg/cmd/rollout/rollout_history.go new file mode 100644 index 000000000..d82c94fbf --- /dev/null +++ b/pkg/cmd/rollout/rollout_history.go @@ -0,0 +1,179 @@ +/* +Copyright 2016 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 rollout + +import ( + "fmt" + + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + historyLong = templates.LongDesc(` + View previous rollout revisions and configurations.`) + + historyExample = templates.Examples(` + # View the rollout history of a deployment + kubectl rollout history deployment/abc + + # View the details of daemonset revision 3 + kubectl rollout history daemonset/abc --revision=3`) +) + +// RolloutHistoryOptions holds the options for 'rollout history' sub command +type RolloutHistoryOptions struct { + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + Revision int64 + + Builder func() *resource.Builder + Resources []string + Namespace string + EnforceNamespace bool + + HistoryViewer polymorphichelpers.HistoryViewerFunc + RESTClientGetter genericclioptions.RESTClientGetter + + resource.FilenameOptions + genericclioptions.IOStreams +} + +// NewRolloutHistoryOptions returns an initialized RolloutHistoryOptions instance +func NewRolloutHistoryOptions(streams genericclioptions.IOStreams) *RolloutHistoryOptions { + return &RolloutHistoryOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithTypeSetter(scheme.Scheme), + IOStreams: streams, + } +} + +// NewCmdRolloutHistory returns a Command instance for RolloutHistory sub command +func NewCmdRolloutHistory(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewRolloutHistoryOptions(streams) + + validArgs := []string{"deployment", "daemonset", "statefulset"} + + cmd := &cobra.Command{ + Use: "history (TYPE NAME | TYPE/NAME) [flags]", + DisableFlagsInUseLine: true, + Short: i18n.T("View rollout history"), + Long: historyLong, + Example: historyExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run()) + }, + ValidArgs: validArgs, + } + + cmd.Flags().Int64Var(&o.Revision, "revision", o.Revision, "See the details, including podTemplate of the revision specified") + + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + + o.PrintFlags.AddFlags(cmd) + + return cmd +} + +// Complete completes al the required options +func (o *RolloutHistoryOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + o.Resources = args + + var err error + if o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace(); err != nil { + return err + } + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + return o.PrintFlags.ToPrinter() + } + + o.HistoryViewer = polymorphichelpers.HistoryViewerFn + o.RESTClientGetter = f + o.Builder = f.NewBuilder + + return nil +} + +// Validate makes sure all the provided values for command-line options are valid +func (o *RolloutHistoryOptions) Validate() error { + if len(o.Resources) == 0 && cmdutil.IsFilenameSliceEmpty(o.Filenames, o.Kustomize) { + return fmt.Errorf("required resource not specified") + } + if o.Revision < 0 { + return fmt.Errorf("revision must be a positive integer: %v", o.Revision) + } + + return nil +} + +// Run performs the execution of 'rollout history' sub command +func (o *RolloutHistoryOptions) Run() error { + + r := o.Builder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.FilenameOptions). + ResourceTypeOrNameArgs(true, o.Resources...). + ContinueOnError(). + Latest(). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + return r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + mapping := info.ResourceMapping() + historyViewer, err := o.HistoryViewer(o.RESTClientGetter, mapping) + if err != nil { + return err + } + historyInfo, err := historyViewer.ViewHistory(info.Namespace, info.Name, o.Revision) + if err != nil { + return err + } + + withRevision := "" + if o.Revision > 0 { + withRevision = fmt.Sprintf("with revision #%d", o.Revision) + } + + printer, err := o.ToPrinter(fmt.Sprintf("%s\n%s", withRevision, historyInfo)) + if err != nil { + return err + } + + return printer.PrintObj(info.Object, o.Out) + }) +} diff --git a/pkg/cmd/rollout/rollout_pause.go b/pkg/cmd/rollout/rollout_pause.go new file mode 100644 index 000000000..61ba901cc --- /dev/null +++ b/pkg/cmd/rollout/rollout_pause.go @@ -0,0 +1,194 @@ +/* +Copyright 2016 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 rollout + +import ( + "fmt" + + "github.com/spf13/cobra" + + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubernetes/pkg/kubectl/cmd/set" +) + +// PauseOptions is the start of the data required to perform the operation. As new fields are added, add them here instead of +// referencing the cmd.Flags() +type PauseOptions struct { + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + Pauser polymorphichelpers.ObjectPauserFunc + Builder func() *resource.Builder + Namespace string + EnforceNamespace bool + Resources []string + + resource.FilenameOptions + genericclioptions.IOStreams +} + +var ( + pauseLong = templates.LongDesc(` + Mark the provided resource as paused + + Paused resources will not be reconciled by a controller. + Use "kubectl rollout resume" to resume a paused resource. + Currently only deployments support being paused.`) + + pauseExample = templates.Examples(` + # Mark the nginx deployment as paused. Any current state of + # the deployment will continue its function, new updates to the deployment will not + # have an effect as long as the deployment is paused. + kubectl rollout pause deployment/nginx`) +) + +// NewCmdRolloutPause returns a Command instance for 'rollout pause' sub command +func NewCmdRolloutPause(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := &PauseOptions{ + PrintFlags: genericclioptions.NewPrintFlags("paused").WithTypeSetter(scheme.Scheme), + IOStreams: streams, + } + + validArgs := []string{"deployment"} + + cmd := &cobra.Command{ + Use: "pause RESOURCE", + DisableFlagsInUseLine: true, + Short: i18n.T("Mark the provided resource as paused"), + Long: pauseLong, + Example: pauseExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunPause()) + }, + ValidArgs: validArgs, + } + + o.PrintFlags.AddFlags(cmd) + + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + return cmd +} + +// Complete completes all the required options +func (o *PauseOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + o.Pauser = polymorphichelpers.ObjectPauserFn + + var err error + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.Resources = args + o.Builder = f.NewBuilder + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + return o.PrintFlags.ToPrinter() + } + + return nil +} + +func (o *PauseOptions) Validate() error { + if len(o.Resources) == 0 && cmdutil.IsFilenameSliceEmpty(o.Filenames, o.Kustomize) { + return fmt.Errorf("required resource not specified") + } + return nil +} + +// RunPause performs the execution of 'rollout pause' sub command +func (o *PauseOptions) RunPause() error { + r := o.Builder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.FilenameOptions). + ResourceTypeOrNameArgs(true, o.Resources...). + ContinueOnError(). + Latest(). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + allErrs := []error{} + infos, err := r.Infos() + if err != nil { + // restore previous command behavior where + // an error caused by retrieving infos due to + // at least a single broken object did not result + // in an immediate return, but rather an overall + // aggregation of errors. + allErrs = append(allErrs, err) + } + + for _, patch := range set.CalculatePatches(infos, scheme.DefaultJSONEncoder(), set.PatchFn(o.Pauser)) { + info := patch.Info + + if patch.Err != nil { + resourceString := info.Mapping.Resource.Resource + if len(info.Mapping.Resource.Group) > 0 { + resourceString = resourceString + "." + info.Mapping.Resource.Group + } + allErrs = append(allErrs, fmt.Errorf("error: %s %q %v", resourceString, info.Name, patch.Err)) + continue + } + + if string(patch.Patch) == "{}" || len(patch.Patch) == 0 { + printer, err := o.ToPrinter("already paused") + if err != nil { + allErrs = append(allErrs, err) + continue + } + if err = printer.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + continue + } + + obj, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch.Patch, nil) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to patch: %v", err)) + continue + } + + info.Refresh(obj, true) + printer, err := o.ToPrinter("paused") + if err != nil { + allErrs = append(allErrs, err) + continue + } + if err = printer.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + } + + return utilerrors.NewAggregate(allErrs) +} diff --git a/pkg/cmd/rollout/rollout_pause_test.go b/pkg/cmd/rollout/rollout_pause_test.go new file mode 100644 index 000000000..0c0c4dda0 --- /dev/null +++ b/pkg/cmd/rollout/rollout_pause_test.go @@ -0,0 +1,115 @@ +/* +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 rollout + +import ( + "bytes" + "io/ioutil" + "net/http" + "net/url" + "testing" + + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +var rolloutPauseGroupVersionEncoder = schema.GroupVersion{Group: "extensions", Version: "v1beta1"} +var rolloutPauseGroupVersionDecoder = schema.GroupVersion{Group: "extensions", Version: "v1beta1"} + +func TestRolloutPause(t *testing.T) { + deploymentName := "deployment/nginx-deployment" + ns := scheme.Codecs + tf := cmdtesting.NewTestFactory().WithNamespace("test") + + info, _ := runtime.SerializerInfoForMediaType(ns.SupportedMediaTypes(), runtime.ContentTypeJSON) + encoder := ns.EncoderForVersion(info.Serializer, rolloutPauseGroupVersionEncoder) + tf.Client = &RolloutPauseRESTClient{ + RESTClient: &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/deployments/nginx-deployment" && (m == "GET" || m == "PATCH"): + responseDeployment := &extensionsv1beta1.Deployment{} + responseDeployment.Name = deploymentName + body := ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(encoder, responseDeployment)))) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Fatalf("unexpected request: %#v\n%#v", req.URL, req) + return nil, nil + } + }), + }, + } + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdRolloutPause(tf, streams) + + cmd.Run(cmd, []string{deploymentName}) + expectedOutput := "deployment.extensions/" + deploymentName + " paused\n" + if buf.String() != expectedOutput { + t.Errorf("expected output: %s, but got: %s", expectedOutput, buf.String()) + } +} + +type RolloutPauseRESTClient struct { + *fake.RESTClient +} + +func (c *RolloutPauseRESTClient) Get() *restclient.Request { + config := restclient.ContentConfig{ + ContentType: runtime.ContentTypeJSON, + GroupVersion: &rolloutPauseGroupVersionEncoder, + NegotiatedSerializer: c.NegotiatedSerializer, + } + + info, _ := runtime.SerializerInfoForMediaType(c.NegotiatedSerializer.SupportedMediaTypes(), runtime.ContentTypeJSON) + serializers := restclient.Serializers{ + Encoder: c.NegotiatedSerializer.EncoderForVersion(info.Serializer, rolloutPauseGroupVersionEncoder), + Decoder: c.NegotiatedSerializer.DecoderToVersion(info.Serializer, rolloutPauseGroupVersionDecoder), + } + if info.StreamSerializer != nil { + serializers.StreamingSerializer = info.StreamSerializer.Serializer + serializers.Framer = info.StreamSerializer.Framer + } + return restclient.NewRequest(c, "GET", &url.URL{Host: "localhost"}, c.VersionedAPIPath, config, serializers, nil, nil, 0) +} + +func (c *RolloutPauseRESTClient) Patch(pt types.PatchType) *restclient.Request { + config := restclient.ContentConfig{ + ContentType: runtime.ContentTypeJSON, + GroupVersion: &rolloutPauseGroupVersionEncoder, + NegotiatedSerializer: c.NegotiatedSerializer, + } + + info, _ := runtime.SerializerInfoForMediaType(c.NegotiatedSerializer.SupportedMediaTypes(), runtime.ContentTypeJSON) + serializers := restclient.Serializers{ + Encoder: c.NegotiatedSerializer.EncoderForVersion(info.Serializer, rolloutPauseGroupVersionEncoder), + Decoder: c.NegotiatedSerializer.DecoderToVersion(info.Serializer, rolloutPauseGroupVersionDecoder), + } + if info.StreamSerializer != nil { + serializers.StreamingSerializer = info.StreamSerializer.Serializer + serializers.Framer = info.StreamSerializer.Framer + } + return restclient.NewRequest(c, "PATCH", &url.URL{Host: "localhost"}, c.VersionedAPIPath, config, serializers, nil, nil, 0) +} diff --git a/pkg/cmd/rollout/rollout_restart.go b/pkg/cmd/rollout/rollout_restart.go new file mode 100644 index 000000000..7d60f2165 --- /dev/null +++ b/pkg/cmd/rollout/rollout_restart.go @@ -0,0 +1,190 @@ +/* +Copyright 2019 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 rollout + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubernetes/pkg/kubectl/cmd/set" +) + +// RestartOptions is the start of the data required to perform the operation. As new fields are added, add them here instead of +// referencing the cmd.Flags() +type RestartOptions struct { + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + Resources []string + + Builder func() *resource.Builder + Restarter polymorphichelpers.ObjectRestarterFunc + Namespace string + EnforceNamespace bool + + resource.FilenameOptions + genericclioptions.IOStreams +} + +var ( + restartLong = templates.LongDesc(` + Restart a resource. + + Resource will be rollout restarted.`) + + restartExample = templates.Examples(` + # Restart a deployment + kubectl rollout restart deployment/nginx + + # Restart a daemonset + kubectl rollout restart daemonset/abc`) +) + +// NewRolloutRestartOptions returns an initialized RestartOptions instance +func NewRolloutRestartOptions(streams genericclioptions.IOStreams) *RestartOptions { + return &RestartOptions{ + PrintFlags: genericclioptions.NewPrintFlags("restarted").WithTypeSetter(scheme.Scheme), + IOStreams: streams, + } +} + +// NewCmdRolloutRestart returns a Command instance for 'rollout restart' sub command +func NewCmdRolloutRestart(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewRolloutRestartOptions(streams) + + validArgs := []string{"deployment", "daemonset", "statefulset"} + + cmd := &cobra.Command{ + Use: "restart RESOURCE", + DisableFlagsInUseLine: true, + Short: i18n.T("Restart a resource"), + Long: restartLong, + Example: restartExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunRestart()) + }, + ValidArgs: validArgs, + } + + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + o.PrintFlags.AddFlags(cmd) + return cmd +} + +// Complete completes all the required options +func (o *RestartOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + o.Resources = args + + o.Restarter = polymorphichelpers.ObjectRestarterFn + + var err error + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + return o.PrintFlags.ToPrinter() + } + + o.Builder = f.NewBuilder + + return nil +} + +func (o *RestartOptions) Validate() error { + if len(o.Resources) == 0 && cmdutil.IsFilenameSliceEmpty(o.Filenames, o.Kustomize) { + return fmt.Errorf("required resource not specified") + } + return nil +} + +// RunRestart performs the execution of 'rollout restart' sub command +func (o RestartOptions) RunRestart() error { + r := o.Builder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.FilenameOptions). + ResourceTypeOrNameArgs(true, o.Resources...). + ContinueOnError(). + Latest(). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + allErrs := []error{} + infos, err := r.Infos() + if err != nil { + // restore previous command behavior where + // an error caused by retrieving infos due to + // at least a single broken object did not result + // in an immediate return, but rather an overall + // aggregation of errors. + allErrs = append(allErrs, err) + } + + for _, patch := range set.CalculatePatches(infos, scheme.DefaultJSONEncoder(), set.PatchFn(o.Restarter)) { + info := patch.Info + + if patch.Err != nil { + resourceString := info.Mapping.Resource.Resource + if len(info.Mapping.Resource.Group) > 0 { + resourceString = resourceString + "." + info.Mapping.Resource.Group + } + allErrs = append(allErrs, fmt.Errorf("error: %s %q %v", resourceString, info.Name, patch.Err)) + continue + } + + if string(patch.Patch) == "{}" || len(patch.Patch) == 0 { + allErrs = append(allErrs, fmt.Errorf("failed to create patch for %v: empty patch", info.Name)) + } + + obj, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch.Patch, nil) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to patch: %v", err)) + continue + } + + info.Refresh(obj, true) + printer, err := o.ToPrinter("restarted") + if err != nil { + allErrs = append(allErrs, err) + continue + } + if err = printer.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + } + + return utilerrors.NewAggregate(allErrs) +} diff --git a/pkg/cmd/rollout/rollout_resume.go b/pkg/cmd/rollout/rollout_resume.go new file mode 100644 index 000000000..eb96500d5 --- /dev/null +++ b/pkg/cmd/rollout/rollout_resume.go @@ -0,0 +1,198 @@ +/* +Copyright 2016 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 rollout + +import ( + "fmt" + + "github.com/spf13/cobra" + + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubernetes/pkg/kubectl/cmd/set" +) + +// ResumeOptions is the start of the data required to perform the operation. As new fields are added, add them here instead of +// referencing the cmd.Flags() +type ResumeOptions struct { + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + Resources []string + + Builder func() *resource.Builder + Resumer polymorphichelpers.ObjectResumerFunc + Namespace string + EnforceNamespace bool + + resource.FilenameOptions + genericclioptions.IOStreams +} + +var ( + resumeLong = templates.LongDesc(` + Resume a paused resource + + Paused resources will not be reconciled by a controller. By resuming a + resource, we allow it to be reconciled again. + Currently only deployments support being resumed.`) + + resumeExample = templates.Examples(` + # Resume an already paused deployment + kubectl rollout resume deployment/nginx`) +) + +// NewRolloutResumeOptions returns an initialized ResumeOptions instance +func NewRolloutResumeOptions(streams genericclioptions.IOStreams) *ResumeOptions { + return &ResumeOptions{ + PrintFlags: genericclioptions.NewPrintFlags("resumed").WithTypeSetter(scheme.Scheme), + IOStreams: streams, + } +} + +// NewCmdRolloutResume returns a Command instance for 'rollout resume' sub command +func NewCmdRolloutResume(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewRolloutResumeOptions(streams) + + validArgs := []string{"deployment"} + + cmd := &cobra.Command{ + Use: "resume RESOURCE", + DisableFlagsInUseLine: true, + Short: i18n.T("Resume a paused resource"), + Long: resumeLong, + Example: resumeExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunResume()) + }, + ValidArgs: validArgs, + } + + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + o.PrintFlags.AddFlags(cmd) + return cmd +} + +// Complete completes all the required options +func (o *ResumeOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + o.Resources = args + + o.Resumer = polymorphichelpers.ObjectResumerFn + + var err error + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + return o.PrintFlags.ToPrinter() + } + + o.Builder = f.NewBuilder + + return nil +} + +func (o *ResumeOptions) Validate() error { + if len(o.Resources) == 0 && cmdutil.IsFilenameSliceEmpty(o.Filenames, o.Kustomize) { + return fmt.Errorf("required resource not specified") + } + return nil +} + +// RunResume performs the execution of 'rollout resume' sub command +func (o ResumeOptions) RunResume() error { + r := o.Builder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.FilenameOptions). + ResourceTypeOrNameArgs(true, o.Resources...). + ContinueOnError(). + Latest(). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + allErrs := []error{} + infos, err := r.Infos() + if err != nil { + // restore previous command behavior where + // an error caused by retrieving infos due to + // at least a single broken object did not result + // in an immediate return, but rather an overall + // aggregation of errors. + allErrs = append(allErrs, err) + } + + for _, patch := range set.CalculatePatches(infos, scheme.DefaultJSONEncoder(), set.PatchFn(o.Resumer)) { + info := patch.Info + + if patch.Err != nil { + resourceString := info.Mapping.Resource.Resource + if len(info.Mapping.Resource.Group) > 0 { + resourceString = resourceString + "." + info.Mapping.Resource.Group + } + allErrs = append(allErrs, fmt.Errorf("error: %s %q %v", resourceString, info.Name, patch.Err)) + continue + } + + if string(patch.Patch) == "{}" || len(patch.Patch) == 0 { + printer, err := o.ToPrinter("already resumed") + if err != nil { + allErrs = append(allErrs, err) + continue + } + if err = printer.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + continue + } + + obj, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch.Patch, nil) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to patch: %v", err)) + continue + } + + info.Refresh(obj, true) + printer, err := o.ToPrinter("resumed") + if err != nil { + allErrs = append(allErrs, err) + continue + } + if err = printer.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + } + + return utilerrors.NewAggregate(allErrs) +} diff --git a/pkg/cmd/rollout/rollout_status.go b/pkg/cmd/rollout/rollout_status.go new file mode 100644 index 000000000..a44aea40b --- /dev/null +++ b/pkg/cmd/rollout/rollout_status.go @@ -0,0 +1,251 @@ +/* +Copyright 2016 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 rollout + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/tools/cache" + watchtools "k8s.io/client-go/tools/watch" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/interrupt" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + statusLong = templates.LongDesc(` + Show the status of the rollout. + + By default 'rollout status' will watch the status of the latest rollout + until it's done. If you don't want to wait for the rollout to finish then + you can use --watch=false. Note that if a new rollout starts in-between, then + 'rollout status' will continue watching the latest revision. If you want to + pin to a specific revision and abort if it is rolled over by another revision, + use --revision=N where N is the revision you need to watch for.`) + + statusExample = templates.Examples(` + # Watch the rollout status of a deployment + kubectl rollout status deployment/nginx`) +) + +// RolloutStatusOptions holds the command-line options for 'rollout status' sub command +type RolloutStatusOptions struct { + PrintFlags *genericclioptions.PrintFlags + + Namespace string + EnforceNamespace bool + BuilderArgs []string + + Watch bool + Revision int64 + Timeout time.Duration + + StatusViewerFn func(*meta.RESTMapping) (polymorphichelpers.StatusViewer, error) + Builder func() *resource.Builder + DynamicClient dynamic.Interface + + FilenameOptions *resource.FilenameOptions + genericclioptions.IOStreams +} + +// NewRolloutStatusOptions returns an initialized RolloutStatusOptions instance +func NewRolloutStatusOptions(streams genericclioptions.IOStreams) *RolloutStatusOptions { + return &RolloutStatusOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithTypeSetter(scheme.Scheme), + FilenameOptions: &resource.FilenameOptions{}, + IOStreams: streams, + Watch: true, + Timeout: 0, + } +} + +// NewCmdRolloutStatus returns a Command instance for the 'rollout status' sub command +func NewCmdRolloutStatus(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewRolloutStatusOptions(streams) + + validArgs := []string{"deployment", "daemonset", "statefulset"} + + cmd := &cobra.Command{ + Use: "status (TYPE NAME | TYPE/NAME) [flags]", + DisableFlagsInUseLine: true, + Short: i18n.T("Show the status of the rollout"), + Long: statusLong, + Example: statusExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run()) + }, + ValidArgs: validArgs, + } + + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, o.FilenameOptions, usage) + cmd.Flags().BoolVarP(&o.Watch, "watch", "w", o.Watch, "Watch the status of the rollout until it's done.") + cmd.Flags().Int64Var(&o.Revision, "revision", o.Revision, "Pin to a specific revision for showing its status. Defaults to 0 (last revision).") + cmd.Flags().DurationVar(&o.Timeout, "timeout", o.Timeout, "The length of time to wait before ending watch, zero means never. Any other values should contain a corresponding time unit (e.g. 1s, 2m, 3h).") + + return cmd +} + +// Complete completes all the required options +func (o *RolloutStatusOptions) Complete(f cmdutil.Factory, args []string) error { + o.Builder = f.NewBuilder + + var err error + o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.BuilderArgs = args + o.StatusViewerFn = polymorphichelpers.StatusViewerFn + + clientConfig, err := f.ToRESTConfig() + if err != nil { + return err + } + + o.DynamicClient, err = dynamic.NewForConfig(clientConfig) + if err != nil { + return err + } + + return nil +} + +// Validate makes sure all the provided values for command-line options are valid +func (o *RolloutStatusOptions) Validate() error { + if len(o.BuilderArgs) == 0 && cmdutil.IsFilenameSliceEmpty(o.FilenameOptions.Filenames, o.FilenameOptions.Kustomize) { + return fmt.Errorf("required resource not specified") + } + + if o.Revision < 0 { + return fmt.Errorf("revision must be a positive integer: %v", o.Revision) + } + + return nil +} + +// Run performs the execution of 'rollout status' sub command +func (o *RolloutStatusOptions) Run() error { + r := o.Builder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, o.FilenameOptions). + ResourceTypeOrNameArgs(true, o.BuilderArgs...). + SingleResourceType(). + Latest(). + Do() + err := r.Err() + if err != nil { + return err + } + + infos, err := r.Infos() + if err != nil { + return err + } + if len(infos) != 1 { + return fmt.Errorf("rollout status is only supported on individual resources and resource collections - %d resources were found", len(infos)) + } + info := infos[0] + mapping := info.ResourceMapping() + + statusViewer, err := o.StatusViewerFn(mapping) + if err != nil { + return err + } + + fieldSelector := fields.OneTermEqualSelector("metadata.name", info.Name).String() + lw := &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + options.FieldSelector = fieldSelector + return o.DynamicClient.Resource(info.Mapping.Resource).Namespace(info.Namespace).List(options) + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + options.FieldSelector = fieldSelector + return o.DynamicClient.Resource(info.Mapping.Resource).Namespace(info.Namespace).Watch(options) + }, + } + + preconditionFunc := func(store cache.Store) (bool, error) { + _, exists, err := store.Get(&metav1.ObjectMeta{Namespace: info.Namespace, Name: info.Name}) + if err != nil { + return true, err + } + if !exists { + // We need to make sure we see the object in the cache before we start waiting for events + // or we would be waiting for the timeout if such object didn't exist. + return true, apierrors.NewNotFound(mapping.Resource.GroupResource(), info.Name) + } + + return false, nil + } + + // if the rollout isn't done yet, keep watching deployment status + ctx, cancel := watchtools.ContextWithOptionalTimeout(context.Background(), o.Timeout) + intr := interrupt.New(nil, cancel) + return intr.Run(func() error { + _, err = watchtools.UntilWithSync(ctx, lw, &unstructured.Unstructured{}, preconditionFunc, func(e watch.Event) (bool, error) { + switch t := e.Type; t { + case watch.Added, watch.Modified: + status, done, err := statusViewer.Status(e.Object.(runtime.Unstructured), o.Revision) + if err != nil { + return false, err + } + fmt.Fprintf(o.Out, "%s", status) + // Quit waiting if the rollout is done + if done { + return true, nil + } + + shouldWatch := o.Watch + if !shouldWatch { + return true, nil + } + + return false, nil + + case watch.Deleted: + // We need to abort to avoid cases of recreation and not to silently watch the wrong (new) object + return true, fmt.Errorf("object has been deleted") + + default: + return true, fmt.Errorf("internal error: unexpected event %#v", e) + } + }) + return err + }) +} diff --git a/pkg/cmd/rollout/rollout_undo.go b/pkg/cmd/rollout/rollout_undo.go new file mode 100644 index 000000000..8631a999c --- /dev/null +++ b/pkg/cmd/rollout/rollout_undo.go @@ -0,0 +1,173 @@ +/* +Copyright 2016 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 rollout + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/polymorphichelpers" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// UndoOptions is the start of the data required to perform the operation. As new fields are added, add them here instead of +// referencing the cmd.Flags() +type UndoOptions struct { + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + Builder func() *resource.Builder + ToRevision int64 + DryRun bool + Resources []string + Namespace string + EnforceNamespace bool + RESTClientGetter genericclioptions.RESTClientGetter + + resource.FilenameOptions + genericclioptions.IOStreams +} + +var ( + undoLong = templates.LongDesc(` + Rollback to a previous rollout.`) + + undoExample = templates.Examples(` + # Rollback to the previous deployment + kubectl rollout undo deployment/abc + + # Rollback to daemonset revision 3 + kubectl rollout undo daemonset/abc --to-revision=3 + + # Rollback to the previous deployment with dry-run + kubectl rollout undo --dry-run=true deployment/abc`) +) + +// NewRolloutUndoOptions returns an initialized UndoOptions instance +func NewRolloutUndoOptions(streams genericclioptions.IOStreams) *UndoOptions { + return &UndoOptions{ + PrintFlags: genericclioptions.NewPrintFlags("rolled back").WithTypeSetter(scheme.Scheme), + IOStreams: streams, + ToRevision: int64(0), + } +} + +// NewCmdRolloutUndo returns a Command instance for the 'rollout undo' sub command +func NewCmdRolloutUndo(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewRolloutUndoOptions(streams) + + validArgs := []string{"deployment", "daemonset", "statefulset"} + + cmd := &cobra.Command{ + Use: "undo (TYPE NAME | TYPE/NAME) [flags]", + DisableFlagsInUseLine: true, + Short: i18n.T("Undo a previous rollout"), + Long: undoLong, + Example: undoExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunUndo()) + }, + ValidArgs: validArgs, + } + + cmd.Flags().Int64Var(&o.ToRevision, "to-revision", o.ToRevision, "The revision to rollback to. Default to 0 (last revision).") + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + cmdutil.AddDryRunFlag(cmd) + o.PrintFlags.AddFlags(cmd) + return cmd +} + +// Complete completes al the required options +func (o *UndoOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + o.Resources = args + o.DryRun = cmdutil.GetDryRunFlag(cmd) + + var err error + if o.Namespace, o.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace(); err != nil { + return err + } + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + return o.PrintFlags.ToPrinter() + } + + o.RESTClientGetter = f + o.Builder = f.NewBuilder + + return err +} + +func (o *UndoOptions) Validate() error { + if len(o.Resources) == 0 && cmdutil.IsFilenameSliceEmpty(o.Filenames, o.Kustomize) { + return fmt.Errorf("required resource not specified") + } + return nil +} + +// RunUndo performs the execution of 'rollout undo' sub command +func (o *UndoOptions) RunUndo() error { + r := o.Builder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + NamespaceParam(o.Namespace).DefaultNamespace(). + FilenameParam(o.EnforceNamespace, &o.FilenameOptions). + ResourceTypeOrNameArgs(true, o.Resources...). + ContinueOnError(). + Latest(). + Flatten(). + Do() + if err := r.Err(); err != nil { + return err + } + + err := r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + rollbacker, err := polymorphichelpers.RollbackerFn(o.RESTClientGetter, info.ResourceMapping()) + if err != nil { + return err + } + + result, err := rollbacker.Rollback(info.Object, nil, o.ToRevision, o.DryRun) + if err != nil { + return err + } + + printer, err := o.ToPrinter(result) + if err != nil { + return err + } + + return printer.PrintObj(info.Object, o.Out) + }) + + return err +} diff --git a/pkg/cmd/run/run.go b/pkg/cmd/run/run.go new file mode 100644 index 000000000..ace13a743 --- /dev/null +++ b/pkg/cmd/run/run.go @@ -0,0 +1,770 @@ +/* +Copyright 2014 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 run + +import ( + "context" + "fmt" + "time" + + "github.com/docker/distribution/reference" + "github.com/spf13/cobra" + "k8s.io/klog" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/tools/cache" + watchtools "k8s.io/client-go/tools/watch" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/generate" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/interrupt" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubernetes/pkg/kubectl/cmd/attach" + "k8s.io/kubernetes/pkg/kubectl/cmd/delete" + "k8s.io/kubernetes/pkg/kubectl/cmd/exec" + "k8s.io/kubernetes/pkg/kubectl/cmd/logs" + uexec "k8s.io/utils/exec" +) + +var ( + runLong = templates.LongDesc(i18n.T(` + Create and run a particular image, possibly replicated. + + Creates a deployment or job to manage the created container(s).`)) + + runExample = templates.Examples(i18n.T(` + # Start a single instance of nginx. + kubectl run nginx --image=nginx + + # Start a single instance of hazelcast and let the container expose port 5701 . + kubectl run hazelcast --image=hazelcast --port=5701 + + # Start a single instance of hazelcast and set environment variables "DNS_DOMAIN=cluster" and "POD_NAMESPACE=default" in the container. + kubectl run hazelcast --image=hazelcast --env="DNS_DOMAIN=cluster" --env="POD_NAMESPACE=default" + + # Start a single instance of hazelcast and set labels "app=hazelcast" and "env=prod" in the container. + kubectl run hazelcast --image=hazelcast --labels="app=hazelcast,env=prod" + + # Start a replicated instance of nginx. + kubectl run nginx --image=nginx --replicas=5 + + # Dry run. Print the corresponding API objects without creating them. + kubectl run nginx --image=nginx --dry-run + + # Start a single instance of nginx, but overload the spec of the deployment with a partial set of values parsed from JSON. + kubectl run nginx --image=nginx --overrides='{ "apiVersion": "v1", "spec": { ... } }' + + # Start a pod of busybox and keep it in the foreground, don't restart it if it exits. + kubectl run -i -t busybox --image=busybox --restart=Never + + # Start the nginx container using the default command, but use custom arguments (arg1 .. argN) for that command. + kubectl run nginx --image=nginx -- ... + + # Start the nginx container using a different command and custom arguments. + kubectl run nginx --image=nginx --command -- ... + + # Start the perl container to compute π to 2000 places and print it out. + kubectl run pi --image=perl --restart=OnFailure -- perl -Mbignum=bpi -wle 'print bpi(2000)' + + # Start the cron job to compute π to 2000 places and print it out every 5 minutes. + kubectl run pi --schedule="0/5 * * * ?" --image=perl --restart=OnFailure -- perl -Mbignum=bpi -wle 'print bpi(2000)'`)) +) + +const ( + defaultPodAttachTimeout = 60 * time.Second +) + +var metadataAccessor = meta.NewAccessor() + +type RunObject struct { + Object runtime.Object + Mapping *meta.RESTMapping +} + +type RunOptions struct { + PrintFlags *genericclioptions.PrintFlags + RecordFlags *genericclioptions.RecordFlags + + DeleteFlags *delete.DeleteFlags + DeleteOptions *delete.DeleteOptions + + DryRun bool + + PrintObj func(runtime.Object) error + Recorder genericclioptions.Recorder + + DynamicClient dynamic.Interface + + ArgsLenAtDash int + Attach bool + Expose bool + Generator string + Image string + Interactive bool + LeaveStdinOpen bool + Port string + Quiet bool + Schedule string + TTY bool + + genericclioptions.IOStreams +} + +func NewRunOptions(streams genericclioptions.IOStreams) *RunOptions { + return &RunOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + DeleteFlags: delete.NewDeleteFlags("to use to replace the resource."), + RecordFlags: genericclioptions.NewRecordFlags(), + + Recorder: genericclioptions.NoopRecorder{}, + + IOStreams: streams, + } +} + +func NewCmdRun(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewRunOptions(streams) + + cmd := &cobra.Command{ + Use: "run NAME --image=image [--env=\"key=value\"] [--port=port] [--replicas=replicas] [--dry-run=bool] [--overrides=inline-json] [--command] -- [COMMAND] [args...]", + DisableFlagsInUseLine: true, + Short: i18n.T("Run a particular image on the cluster"), + Long: runLong, + Example: runExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(o.Run(f, cmd, args)) + }, + } + + o.DeleteFlags.AddFlags(cmd) + o.PrintFlags.AddFlags(cmd) + o.RecordFlags.AddFlags(cmd) + + addRunFlags(cmd, o) + cmdutil.AddApplyAnnotationFlags(cmd) + cmdutil.AddPodRunningTimeoutFlag(cmd, defaultPodAttachTimeout) + return cmd +} + +func addRunFlags(cmd *cobra.Command, opt *RunOptions) { + cmdutil.AddDryRunFlag(cmd) + cmd.Flags().StringVar(&opt.Generator, "generator", opt.Generator, i18n.T("The name of the API generator to use, see http://kubernetes.io/docs/user-guide/kubectl-conventions/#generators for a list.")) + cmd.Flags().StringVar(&opt.Image, "image", opt.Image, i18n.T("The image for the container to run.")) + cmd.MarkFlagRequired("image") + cmd.Flags().String("image-pull-policy", "", i18n.T("The image pull policy for the container. If left empty, this value will not be specified by the client and defaulted by the server")) + cmd.Flags().IntP("replicas", "r", 1, "Number of replicas to create for this container. Default is 1.") + cmd.Flags().Bool("rm", false, "If true, delete resources created in this command for attached containers.") + cmd.Flags().String("overrides", "", i18n.T("An inline JSON override for the generated object. If this is non-empty, it is used to override the generated object. Requires that the object supply a valid apiVersion field.")) + cmd.Flags().StringArray("env", []string{}, "Environment variables to set in the container") + cmd.Flags().String("serviceaccount", "", "Service account to set in the pod spec") + cmd.Flags().StringVar(&opt.Port, "port", opt.Port, i18n.T("The port that this container exposes. If --expose is true, this is also the port used by the service that is created.")) + cmd.Flags().Int("hostport", -1, "The host port mapping for the container port. To demonstrate a single-machine container.") + cmd.Flags().StringP("labels", "l", "", "Comma separated labels to apply to the pod(s). Will override previous values.") + cmd.Flags().BoolVarP(&opt.Interactive, "stdin", "i", opt.Interactive, "Keep stdin open on the container(s) in the pod, even if nothing is attached.") + cmd.Flags().BoolVarP(&opt.TTY, "tty", "t", opt.TTY, "Allocated a TTY for each container in the pod.") + cmd.Flags().BoolVar(&opt.Attach, "attach", opt.Attach, "If true, wait for the Pod to start running, and then attach to the Pod as if 'kubectl attach ...' were called. Default false, unless '-i/--stdin' is set, in which case the default is true. With '--restart=Never' the exit code of the container process is returned.") + cmd.Flags().BoolVar(&opt.LeaveStdinOpen, "leave-stdin-open", opt.LeaveStdinOpen, "If the pod is started in interactive mode or with stdin, leave stdin open after the first attach completes. By default, stdin will be closed after the first attach completes.") + cmd.Flags().String("restart", "Always", i18n.T("The restart policy for this Pod. Legal values [Always, OnFailure, Never]. If set to 'Always' a deployment is created, if set to 'OnFailure' a job is created, if set to 'Never', a regular pod is created. For the latter two --replicas must be 1. Default 'Always', for CronJobs `Never`.")) + cmd.Flags().Bool("command", false, "If true and extra arguments are present, use them as the 'command' field in the container, rather than the 'args' field which is the default.") + cmd.Flags().String("requests", "", i18n.T("The resource requirement requests for this container. For example, 'cpu=100m,memory=256Mi'. Note that server side components may assign requests depending on the server configuration, such as limit ranges.")) + cmd.Flags().String("limits", "", i18n.T("The resource requirement limits for this container. For example, 'cpu=200m,memory=512Mi'. Note that server side components may assign limits depending on the server configuration, such as limit ranges.")) + cmd.Flags().BoolVar(&opt.Expose, "expose", opt.Expose, "If true, a public, external service is created for the container(s) which are run") + cmd.Flags().String("service-generator", "service/v2", i18n.T("The name of the generator to use for creating a service. Only used if --expose is true")) + cmd.Flags().String("service-overrides", "", i18n.T("An inline JSON override for the generated service object. If this is non-empty, it is used to override the generated object. Requires that the object supply a valid apiVersion field. Only used if --expose is true.")) + cmd.Flags().BoolVar(&opt.Quiet, "quiet", opt.Quiet, "If true, suppress prompt messages.") + cmd.Flags().StringVar(&opt.Schedule, "schedule", opt.Schedule, i18n.T("A schedule in the Cron format the job should be run with.")) +} + +func (o *RunOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + var err error + + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.DynamicClient, err = f.DynamicClient() + if err != nil { + return err + } + + o.ArgsLenAtDash = cmd.ArgsLenAtDash() + o.DryRun = cmdutil.GetFlagBool(cmd, "dry-run") + + attachFlag := cmd.Flags().Lookup("attach") + if !attachFlag.Changed && o.Interactive { + o.Attach = true + } + + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = func(obj runtime.Object) error { + return printer.PrintObj(obj, o.Out) + } + + deleteOpts := o.DeleteFlags.ToOptions(o.DynamicClient, o.IOStreams) + deleteOpts.IgnoreNotFound = true + deleteOpts.WaitForDeletion = false + deleteOpts.GracePeriod = -1 + deleteOpts.Quiet = o.Quiet + + o.DeleteOptions = deleteOpts + + return nil +} + +func (o *RunOptions) Run(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + // Let kubectl run follow rules for `--`, see #13004 issue + if len(args) == 0 || o.ArgsLenAtDash == 0 { + return cmdutil.UsageErrorf(cmd, "NAME is required for run") + } + + timeout, err := cmdutil.GetPodRunningTimeoutFlag(cmd) + if err != nil { + return cmdutil.UsageErrorf(cmd, "%v", err) + } + + // validate image name + imageName := o.Image + if imageName == "" { + return fmt.Errorf("--image is required") + } + validImageRef := reference.ReferenceRegexp.MatchString(imageName) + if !validImageRef { + return fmt.Errorf("Invalid image name %q: %v", imageName, reference.ErrReferenceInvalidFormat) + } + + if o.TTY && !o.Interactive { + return cmdutil.UsageErrorf(cmd, "-i/--stdin is required for containers with -t/--tty=true") + } + replicas := cmdutil.GetFlagInt(cmd, "replicas") + if o.Interactive && replicas != 1 { + return cmdutil.UsageErrorf(cmd, "-i/--stdin requires that replicas is 1, found %d", replicas) + } + if o.Expose && len(o.Port) == 0 { + return cmdutil.UsageErrorf(cmd, "--port must be set when exposing a service") + } + + namespace, _, err := f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + restartPolicy, err := getRestartPolicy(cmd, o.Interactive) + if err != nil { + return err + } + if restartPolicy != corev1.RestartPolicyAlways && replicas != 1 { + return cmdutil.UsageErrorf(cmd, "--restart=%s requires that --replicas=1, found %d", restartPolicy, replicas) + } + + remove := cmdutil.GetFlagBool(cmd, "rm") + if !o.Attach && remove { + return cmdutil.UsageErrorf(cmd, "--rm should only be used for attached containers") + } + + if o.Attach && o.DryRun { + return cmdutil.UsageErrorf(cmd, "--dry-run can't be used with attached containers options (--attach, --stdin, or --tty)") + } + + if err := verifyImagePullPolicy(cmd); err != nil { + return err + } + + clientset, err := f.KubernetesClientSet() + if err != nil { + return err + } + + generatorName := o.Generator + if len(o.Schedule) != 0 && len(generatorName) == 0 { + generatorName = generateversioned.CronJobV1Beta1GeneratorName + } + if len(generatorName) == 0 { + switch restartPolicy { + case corev1.RestartPolicyAlways: + generatorName = generateversioned.DeploymentAppsV1GeneratorName + case corev1.RestartPolicyOnFailure: + generatorName = generateversioned.JobV1GeneratorName + case corev1.RestartPolicyNever: + generatorName = generateversioned.RunPodV1GeneratorName + } + + // Falling back because the generator was not provided and the default one could be unavailable. + generatorNameTemp, err := generateversioned.FallbackGeneratorNameIfNecessary(generatorName, clientset.Discovery(), o.ErrOut) + if err != nil { + return err + } + if generatorNameTemp != generatorName { + cmdutil.Warning(o.ErrOut, generatorName, generatorNameTemp) + } else { + generatorName = generatorNameTemp + } + } + + // start deprecating all generators except for 'run-pod/v1' which will be + // the only supported on a route to simple kubectl run which should mimic + // docker run + if generatorName != generateversioned.RunPodV1GeneratorName { + fmt.Fprintf(o.ErrOut, "kubectl run --generator=%s is DEPRECATED and will be removed in a future version. Use kubectl run --generator=%s or kubectl create instead.\n", generatorName, generateversioned.RunPodV1GeneratorName) + } + + generators := generateversioned.GeneratorFn("run") + generator, found := generators[generatorName] + if !found { + return cmdutil.UsageErrorf(cmd, "generator %q not found", generatorName) + } + names := generator.ParamNames() + params := generate.MakeParams(cmd, names) + params["name"] = args[0] + if len(args) > 1 { + params["args"] = args[1:] + } + + params["env"] = cmdutil.GetFlagStringArray(cmd, "env") + + var createdObjects = []*RunObject{} + runObject, err := o.createGeneratedObject(f, cmd, generator, names, params, cmdutil.GetFlagString(cmd, "overrides"), namespace) + if err != nil { + return err + } + createdObjects = append(createdObjects, runObject) + + allErrs := []error{} + if o.Expose { + serviceGenerator := cmdutil.GetFlagString(cmd, "service-generator") + if len(serviceGenerator) == 0 { + return cmdutil.UsageErrorf(cmd, "No service generator specified") + } + serviceRunObject, err := o.generateService(f, cmd, serviceGenerator, params, namespace) + if err != nil { + allErrs = append(allErrs, err) + } else { + createdObjects = append(createdObjects, serviceRunObject) + } + } + + if o.Attach { + if remove { + defer o.removeCreatedObjects(f, createdObjects) + } + + opts := &attach.AttachOptions{ + StreamOptions: exec.StreamOptions{ + IOStreams: o.IOStreams, + Stdin: o.Interactive, + TTY: o.TTY, + Quiet: o.Quiet, + }, + GetPodTimeout: timeout, + CommandName: cmd.Parent().CommandPath() + " attach", + + Attach: &attach.DefaultRemoteAttach{}, + } + config, err := f.ToRESTConfig() + if err != nil { + return err + } + opts.Config = config + opts.AttachFunc = attach.DefaultAttachFunc + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return err + } + + attachablePod, err := polymorphichelpers.AttachablePodForObjectFn(f, runObject.Object, opts.GetPodTimeout) + if err != nil { + return err + } + err = handleAttachPod(f, clientset.CoreV1(), attachablePod.Namespace, attachablePod.Name, opts) + if err != nil { + return err + } + + var pod *corev1.Pod + leaveStdinOpen := o.LeaveStdinOpen + waitForExitCode := !leaveStdinOpen && restartPolicy == corev1.RestartPolicyNever + if waitForExitCode { + pod, err = waitForPod(clientset.CoreV1(), attachablePod.Namespace, attachablePod.Name, podCompleted) + if err != nil { + return err + } + } + + // after removal is done, return successfully if we are not interested in the exit code + if !waitForExitCode { + return nil + } + + switch pod.Status.Phase { + case corev1.PodSucceeded: + return nil + case corev1.PodFailed: + unknownRcErr := fmt.Errorf("pod %s/%s failed with unknown exit code", pod.Namespace, pod.Name) + if len(pod.Status.ContainerStatuses) == 0 || pod.Status.ContainerStatuses[0].State.Terminated == nil { + return unknownRcErr + } + // assume here that we have at most one status because kubectl-run only creates one container per pod + rc := pod.Status.ContainerStatuses[0].State.Terminated.ExitCode + if rc == 0 { + return unknownRcErr + } + return uexec.CodeExitError{ + Err: fmt.Errorf("pod %s/%s terminated (%s)\n%s", pod.Namespace, pod.Name, pod.Status.ContainerStatuses[0].State.Terminated.Reason, pod.Status.ContainerStatuses[0].State.Terminated.Message), + Code: int(rc), + } + default: + return fmt.Errorf("pod %s/%s left in phase %s", pod.Namespace, pod.Name, pod.Status.Phase) + } + + } + if runObject != nil { + if err := o.PrintObj(runObject.Object); err != nil { + return err + } + } + + return utilerrors.NewAggregate(allErrs) +} + +func (o *RunOptions) removeCreatedObjects(f cmdutil.Factory, createdObjects []*RunObject) error { + for _, obj := range createdObjects { + namespace, err := metadataAccessor.Namespace(obj.Object) + if err != nil { + return err + } + var name string + name, err = metadataAccessor.Name(obj.Object) + if err != nil { + return err + } + r := f.NewBuilder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + ContinueOnError(). + NamespaceParam(namespace).DefaultNamespace(). + ResourceNames(obj.Mapping.Resource.Resource+"."+obj.Mapping.Resource.Group, name). + Flatten(). + Do() + if err := o.DeleteOptions.DeleteResult(r); err != nil { + return err + } + } + + return nil +} + +// waitForPod watches the given pod until the exitCondition is true +func waitForPod(podClient corev1client.PodsGetter, ns, name string, exitCondition watchtools.ConditionFunc) (*corev1.Pod, error) { + // TODO: expose the timeout + ctx, cancel := watchtools.ContextWithOptionalTimeout(context.Background(), 0*time.Second) + defer cancel() + + preconditionFunc := func(store cache.Store) (bool, error) { + _, exists, err := store.Get(&metav1.ObjectMeta{Namespace: ns, Name: name}) + if err != nil { + return true, err + } + if !exists { + // We need to make sure we see the object in the cache before we start waiting for events + // or we would be waiting for the timeout if such object didn't exist. + // (e.g. it was deleted before we started informers so they wouldn't even see the delete event) + return true, errors.NewNotFound(corev1.Resource("pods"), name) + } + + return false, nil + } + + fieldSelector := fields.OneTermEqualSelector("metadata.name", name).String() + lw := &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + options.FieldSelector = fieldSelector + return podClient.Pods(ns).List(options) + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + options.FieldSelector = fieldSelector + return podClient.Pods(ns).Watch(options) + }, + } + + intr := interrupt.New(nil, cancel) + var result *corev1.Pod + err := intr.Run(func() error { + ev, err := watchtools.UntilWithSync(ctx, lw, &corev1.Pod{}, preconditionFunc, func(ev watch.Event) (bool, error) { + return exitCondition(ev) + }) + if ev != nil { + result = ev.Object.(*corev1.Pod) + } + return err + }) + + return result, err +} + +func handleAttachPod(f cmdutil.Factory, podClient corev1client.PodsGetter, ns, name string, opts *attach.AttachOptions) error { + pod, err := waitForPod(podClient, ns, name, podRunningAndReady) + if err != nil && err != ErrPodCompleted { + return err + } + + if pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed { + return logOpts(f, pod, opts) + } + + opts.Pod = pod + opts.PodName = name + opts.Namespace = ns + + if opts.AttachFunc == nil { + opts.AttachFunc = attach.DefaultAttachFunc + } + + if err := opts.Run(); err != nil { + fmt.Fprintf(opts.ErrOut, "Error attaching, falling back to logs: %v\n", err) + return logOpts(f, pod, opts) + } + return nil +} + +// logOpts logs output from opts to the pods log. +func logOpts(restClientGetter genericclioptions.RESTClientGetter, pod *corev1.Pod, opts *attach.AttachOptions) error { + ctrName, err := opts.GetContainerName(pod) + if err != nil { + return err + } + + requests, err := polymorphichelpers.LogsForObjectFn(restClientGetter, pod, &corev1.PodLogOptions{Container: ctrName}, opts.GetPodTimeout, false) + if err != nil { + return err + } + for _, request := range requests { + if err := logs.DefaultConsumeRequest(request, opts.Out); err != nil { + return err + } + } + + return nil +} + +func getRestartPolicy(cmd *cobra.Command, interactive bool) (corev1.RestartPolicy, error) { + restart := cmdutil.GetFlagString(cmd, "restart") + if len(restart) == 0 { + if interactive { + return corev1.RestartPolicyOnFailure, nil + } + return corev1.RestartPolicyAlways, nil + } + switch corev1.RestartPolicy(restart) { + case corev1.RestartPolicyAlways: + return corev1.RestartPolicyAlways, nil + case corev1.RestartPolicyOnFailure: + return corev1.RestartPolicyOnFailure, nil + case corev1.RestartPolicyNever: + return corev1.RestartPolicyNever, nil + } + return "", cmdutil.UsageErrorf(cmd, "invalid restart policy: %s", restart) +} + +func verifyImagePullPolicy(cmd *cobra.Command) error { + pullPolicy := cmdutil.GetFlagString(cmd, "image-pull-policy") + switch corev1.PullPolicy(pullPolicy) { + case corev1.PullAlways, corev1.PullIfNotPresent, corev1.PullNever: + return nil + case "": + return nil + } + return cmdutil.UsageErrorf(cmd, "invalid image pull policy: %s", pullPolicy) +} + +func (o *RunOptions) generateService(f cmdutil.Factory, cmd *cobra.Command, serviceGenerator string, paramsIn map[string]interface{}, namespace string) (*RunObject, error) { + generators := generateversioned.GeneratorFn("expose") + generator, found := generators[serviceGenerator] + if !found { + return nil, fmt.Errorf("missing service generator: %s", serviceGenerator) + } + names := generator.ParamNames() + + params := map[string]interface{}{} + for key, value := range paramsIn { + _, isString := value.(string) + if isString { + params[key] = value + } + } + + name, found := params["name"] + if !found || len(name.(string)) == 0 { + return nil, fmt.Errorf("name is a required parameter") + } + selector, found := params["labels"] + if !found || len(selector.(string)) == 0 { + selector = fmt.Sprintf("run=%s", name.(string)) + } + params["selector"] = selector + + if defaultName, found := params["default-name"]; !found || len(defaultName.(string)) == 0 { + params["default-name"] = name + } + + runObject, err := o.createGeneratedObject(f, cmd, generator, names, params, cmdutil.GetFlagString(cmd, "service-overrides"), namespace) + if err != nil { + return nil, err + } + + if err := o.PrintObj(runObject.Object); err != nil { + return nil, err + } + // separate yaml objects + if o.PrintFlags.OutputFormat != nil && *o.PrintFlags.OutputFormat == "yaml" { + fmt.Fprintln(o.Out, "---") + } + + return runObject, nil +} + +func (o *RunOptions) createGeneratedObject(f cmdutil.Factory, cmd *cobra.Command, generator generate.Generator, names []generate.GeneratorParam, params map[string]interface{}, overrides, namespace string) (*RunObject, error) { + err := generate.ValidateParams(names, params) + if err != nil { + return nil, err + } + + // TODO: Validate flag usage against selected generator. More tricky since --expose was added. + obj, err := generator.Generate(params) + if err != nil { + return nil, err + } + + mapper, err := f.ToRESTMapper() + if err != nil { + return nil, err + } + // run has compiled knowledge of the thing is creating + gvks, _, err := scheme.Scheme.ObjectKinds(obj) + if err != nil { + return nil, err + } + mapping, err := mapper.RESTMapping(gvks[0].GroupKind(), gvks[0].Version) + if err != nil { + return nil, err + } + + if len(overrides) > 0 { + codec := runtime.NewCodec(scheme.DefaultJSONEncoder(), scheme.Codecs.UniversalDecoder(scheme.Scheme.PrioritizedVersionsAllGroups()...)) + obj, err = cmdutil.Merge(codec, obj, overrides) + if err != nil { + return nil, err + } + } + + if err := o.Recorder.Record(obj); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + actualObj := obj + if !o.DryRun { + if err := util.CreateOrUpdateAnnotation(cmdutil.GetFlagBool(cmd, cmdutil.ApplyAnnotationsFlag), obj, scheme.DefaultJSONEncoder()); err != nil { + return nil, err + } + client, err := f.ClientForMapping(mapping) + if err != nil { + return nil, err + } + actualObj, err = resource.NewHelper(client, mapping).Create(namespace, false, obj, nil) + if err != nil { + return nil, err + } + } + + return &RunObject{ + Object: actualObj, + Mapping: mapping, + }, nil +} + +// ErrPodCompleted is returned by PodRunning or PodContainerRunning to indicate that +// the pod has already reached completed state. +var ErrPodCompleted = fmt.Errorf("pod ran to completion") + +// podCompleted returns true if the pod has run to completion, false if the pod has not yet +// reached running state, or an error in any other case. +func podCompleted(event watch.Event) (bool, error) { + switch event.Type { + case watch.Deleted: + return false, errors.NewNotFound(schema.GroupResource{Resource: "pods"}, "") + } + switch t := event.Object.(type) { + case *corev1.Pod: + switch t.Status.Phase { + case corev1.PodFailed, corev1.PodSucceeded: + return true, nil + } + } + return false, nil +} + +// podRunningAndReady returns true if the pod is running and ready, false if the pod has not +// yet reached those states, returns ErrPodCompleted if the pod has run to completion, or +// an error in any other case. +func podRunningAndReady(event watch.Event) (bool, error) { + switch event.Type { + case watch.Deleted: + return false, errors.NewNotFound(schema.GroupResource{Resource: "pods"}, "") + } + switch t := event.Object.(type) { + case *corev1.Pod: + switch t.Status.Phase { + case corev1.PodFailed, corev1.PodSucceeded: + return false, ErrPodCompleted + case corev1.PodRunning: + conditions := t.Status.Conditions + if conditions == nil { + return false, nil + } + for i := range conditions { + if conditions[i].Type == corev1.PodReady && + conditions[i].Status == corev1.ConditionTrue { + return true, nil + } + } + } + } + return false, nil +} diff --git a/pkg/cmd/run/run_test.go b/pkg/cmd/run/run_test.go new file mode 100644 index 000000000..ff18cb918 --- /dev/null +++ b/pkg/cmd/run/run_test.go @@ -0,0 +1,537 @@ +/* +Copyright 2014 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 run + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "os" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubernetes/pkg/kubectl/cmd/delete" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestGetRestartPolicy(t *testing.T) { + tests := []struct { + input string + interactive bool + expected corev1.RestartPolicy + expectErr bool + }{ + { + input: "", + expected: corev1.RestartPolicyAlways, + }, + { + input: "", + interactive: true, + expected: corev1.RestartPolicyOnFailure, + }, + { + input: string(corev1.RestartPolicyAlways), + interactive: true, + expected: corev1.RestartPolicyAlways, + }, + { + input: string(corev1.RestartPolicyNever), + interactive: true, + expected: corev1.RestartPolicyNever, + }, + { + input: string(corev1.RestartPolicyAlways), + expected: corev1.RestartPolicyAlways, + }, + { + input: string(corev1.RestartPolicyNever), + expected: corev1.RestartPolicyNever, + }, + { + input: "foo", + expectErr: true, + }, + } + for _, test := range tests { + cmd := &cobra.Command{} + cmd.Flags().String("restart", "", i18n.T("dummy restart flag)")) + cmd.Flags().Lookup("restart").Value.Set(test.input) + policy, err := getRestartPolicy(cmd, test.interactive) + if test.expectErr && err == nil { + t.Error("unexpected non-error") + } + if !test.expectErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + if !test.expectErr && policy != test.expected { + t.Errorf("expected: %s, saw: %s (%s:%v)", test.expected, policy, test.input, test.interactive) + } + } +} + +func TestGetEnv(t *testing.T) { + test := struct { + input []string + expected []string + }{ + input: []string{"a=b", "c=d"}, + expected: []string{"a=b", "c=d"}, + } + cmd := &cobra.Command{} + cmd.Flags().StringSlice("env", test.input, "") + + envStrings := cmdutil.GetFlagStringSlice(cmd, "env") + if len(envStrings) != 2 || !reflect.DeepEqual(envStrings, test.expected) { + t.Errorf("expected: %s, saw: %s", test.expected, envStrings) + } +} + +func TestRunArgsFollowDashRules(t *testing.T) { + one := int32(1) + rc := &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{Name: "rc1", Namespace: "test", ResourceVersion: "18"}, + Spec: corev1.ReplicationControllerSpec{ + Replicas: &one, + }, + } + + tests := []struct { + args []string + argsLenAtDash int + expectError bool + name string + }{ + { + args: []string{}, + argsLenAtDash: -1, + expectError: true, + name: "empty", + }, + { + args: []string{"foo"}, + argsLenAtDash: -1, + expectError: false, + name: "no cmd", + }, + { + args: []string{"foo", "sleep"}, + argsLenAtDash: -1, + expectError: false, + name: "cmd no dash", + }, + { + args: []string{"foo", "sleep"}, + argsLenAtDash: 1, + expectError: false, + name: "cmd has dash", + }, + { + args: []string{"foo", "sleep"}, + argsLenAtDash: 0, + expectError: true, + name: "no name", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + GroupVersion: corev1.SchemeGroupVersion, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/namespaces/test/replicationcontrollers" { + return &http.Response{StatusCode: 201, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, rc)}, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewBuffer([]byte("{}"))), + }, nil + }), + } + + tf.ClientConfigVal = &restclient.Config{} + + cmd := NewCmdRun(tf, genericclioptions.NewTestIOStreamsDiscard()) + cmd.Flags().Set("image", "nginx") + cmd.Flags().Set("generator", "run/v1") + + printFlags := genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme) + printer, err := printFlags.ToPrinter() + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + deleteFlags := delete.NewDeleteFlags("to use to replace the resource.") + opts := &RunOptions{ + PrintFlags: printFlags, + DeleteOptions: deleteFlags.ToOptions(nil, genericclioptions.NewTestIOStreamsDiscard()), + + IOStreams: genericclioptions.NewTestIOStreamsDiscard(), + + Image: "nginx", + Generator: "run/v1", + + PrintObj: func(obj runtime.Object) error { + return printer.PrintObj(obj, os.Stdout) + }, + Recorder: genericclioptions.NoopRecorder{}, + + ArgsLenAtDash: test.argsLenAtDash, + } + + err = opts.Run(tf, cmd, test.args) + if test.expectError && err == nil { + t.Errorf("unexpected non-error (%s)", test.name) + } + if !test.expectError && err != nil { + t.Errorf("unexpected error: %v (%s)", err, test.name) + } + }) + } +} + +func TestGenerateService(t *testing.T) { + tests := []struct { + name string + port string + args []string + serviceGenerator string + params map[string]interface{} + expectErr bool + service corev1.Service + expectPOST bool + }{ + { + name: "basic", + port: "80", + args: []string{"foo"}, + serviceGenerator: "service/v2", + params: map[string]interface{}{ + "name": "foo", + }, + expectErr: false, + service: corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + Protocol: "TCP", + TargetPort: intstr.FromInt(80), + }, + }, + Selector: map[string]string{ + "run": "foo", + }, + }, + }, + expectPOST: true, + }, + { + name: "custom labels", + port: "80", + args: []string{"foo"}, + serviceGenerator: "service/v2", + params: map[string]interface{}{ + "name": "foo", + "labels": "app=bar", + }, + expectErr: false, + service: corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Labels: map[string]string{"app": "bar"}, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Port: 80, + Protocol: "TCP", + TargetPort: intstr.FromInt(80), + }, + }, + Selector: map[string]string{ + "app": "bar", + }, + }, + }, + expectPOST: true, + }, + { + expectErr: true, + name: "missing port", + expectPOST: false, + }, + { + name: "dry-run", + port: "80", + args: []string{"foo"}, + serviceGenerator: "service/v2", + params: map[string]interface{}{ + "name": "foo", + }, + expectErr: false, + expectPOST: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sawPOST := false + tf := cmdtesting.NewTestFactory() + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + tf.Client = &fake.RESTClient{ + GroupVersion: corev1.SchemeGroupVersion, + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case test.expectPOST && m == "POST" && p == "/namespaces/test/services": + sawPOST = true + body := cmdtesting.ObjBody(codec, &test.service) + data, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer req.Body.Close() + svc := &corev1.Service{} + if err := runtime.DecodeInto(codec, data, svc); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Copy things that are defaulted by the system + test.service.Annotations = svc.Annotations + + if !apiequality.Semantic.DeepEqual(&test.service, svc) { + t.Errorf("expected:\n%v\nsaw:\n%v\n", &test.service, svc) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Errorf("%s: unexpected request: %s %#v\n%#v", test.name, req.Method, req.URL, req) + return nil, fmt.Errorf("unexpected request") + } + }), + } + + printFlags := genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme) + printer, err := printFlags.ToPrinter() + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + ioStreams, _, buff, _ := genericclioptions.NewTestIOStreams() + deleteFlags := delete.NewDeleteFlags("to use to replace the resource.") + opts := &RunOptions{ + PrintFlags: printFlags, + DeleteOptions: deleteFlags.ToOptions(nil, genericclioptions.NewTestIOStreamsDiscard()), + + IOStreams: ioStreams, + + Port: test.port, + Recorder: genericclioptions.NoopRecorder{}, + + PrintObj: func(obj runtime.Object) error { + return printer.PrintObj(obj, buff) + }, + } + + cmd := &cobra.Command{} + cmd.Flags().Bool(cmdutil.ApplyAnnotationsFlag, false, "") + cmd.Flags().Bool("record", false, "Record current kubectl command in the resource annotation. If set to false, do not record the command. If set to true, record the command. If not set, default to updating the existing annotation value only if one already exists.") + addRunFlags(cmd, opts) + + if !test.expectPOST { + opts.DryRun = true + } + + if len(test.port) > 0 { + cmd.Flags().Set("port", test.port) + test.params["port"] = test.port + } + + _, err = opts.generateService(tf, cmd, test.serviceGenerator, test.params, "test") + if test.expectErr { + if err == nil { + t.Error("unexpected non-error") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if test.expectPOST != sawPOST { + t.Errorf("expectPost: %v, sawPost: %v", test.expectPOST, sawPOST) + } + }) + } +} + +func TestRunValidations(t *testing.T) { + tests := []struct { + name string + args []string + flags map[string]string + expectedErr string + }{ + { + name: "test missing name error", + expectedErr: "NAME is required", + }, + { + name: "test missing --image error", + args: []string{"test"}, + expectedErr: "--image is required", + }, + { + name: "test invalid image name error", + args: []string{"test"}, + flags: map[string]string{ + "image": "#", + }, + expectedErr: "Invalid image name", + }, + { + name: "test stdin replicas value", + args: []string{"test"}, + flags: map[string]string{ + "image": "busybox", + "stdin": "true", + "replicas": "2", + }, + expectedErr: "stdin requires that replicas is 1", + }, + { + name: "test rm errors when used on non-attached containers", + args: []string{"test"}, + flags: map[string]string{ + "image": "busybox", + "rm": "true", + }, + expectedErr: "rm should only be used for attached containers", + }, + { + name: "test error on attached containers options", + args: []string{"test"}, + flags: map[string]string{ + "image": "busybox", + "attach": "true", + "dry-run": "true", + }, + expectedErr: "can't be used with attached containers options", + }, + { + name: "test error on attached containers options, with value from stdin", + args: []string{"test"}, + flags: map[string]string{ + "image": "busybox", + "stdin": "true", + "dry-run": "true", + }, + expectedErr: "can't be used with attached containers options", + }, + { + name: "test error on attached containers options, with value from stdin and tty", + args: []string{"test"}, + flags: map[string]string{ + "image": "busybox", + "tty": "true", + "stdin": "true", + "dry-run": "true", + }, + expectedErr: "can't be used with attached containers options", + }, + { + name: "test error when tty=true and no stdin provided", + args: []string{"test"}, + flags: map[string]string{ + "image": "busybox", + "tty": "true", + }, + expectedErr: "stdin is required for containers with -t/--tty", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + _, _, codec := cmdtesting.NewExternalScheme() + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: scheme.Codecs, + Resp: &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, cmdtesting.NewInternalType("", "", ""))}, + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + streams, _, _, bufErr := genericclioptions.NewTestIOStreams() + cmdutil.BehaviorOnFatal(func(str string, code int) { + bufErr.Write([]byte(str)) + }) + + cmd := NewCmdRun(tf, streams) + for flagName, flagValue := range test.flags { + cmd.Flags().Set(flagName, flagValue) + } + cmd.Run(cmd, test.args) + + var err error + if bufErr.Len() > 0 { + err = fmt.Errorf("%v", bufErr.String()) + } + if err != nil && len(test.expectedErr) > 0 { + if !strings.Contains(err.Error(), test.expectedErr) { + t.Errorf("unexpected error: %v", err) + } + } + }) + } + +} diff --git a/pkg/cmd/scale/scale.go b/pkg/cmd/scale/scale.go new file mode 100644 index 000000000..1e2e5d71d --- /dev/null +++ b/pkg/cmd/scale/scale.go @@ -0,0 +1,267 @@ +/* +Copyright 2014 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 scale + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/kubernetes" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scale" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + scaleLong = templates.LongDesc(i18n.T(` + Set a new size for a Deployment, ReplicaSet, Replication Controller, or StatefulSet. + + Scale also allows users to specify one or more preconditions for the scale action. + + If --current-replicas or --resource-version is specified, it is validated before the + scale is attempted, and it is guaranteed that the precondition holds true when the + scale is sent to the server.`)) + + scaleExample = templates.Examples(i18n.T(` + # Scale a replicaset named 'foo' to 3. + kubectl scale --replicas=3 rs/foo + + # Scale a resource identified by type and name specified in "foo.yaml" to 3. + kubectl scale --replicas=3 -f foo.yaml + + # If the deployment named mysql's current size is 2, scale mysql to 3. + kubectl scale --current-replicas=2 --replicas=3 deployment/mysql + + # Scale multiple replication controllers. + kubectl scale --replicas=5 rc/foo rc/bar rc/baz + + # Scale statefulset named 'web' to 3. + kubectl scale --replicas=3 statefulset/web`)) +) + +const ( + timeout = 5 * time.Minute +) + +type ScaleOptions struct { + FilenameOptions resource.FilenameOptions + RecordFlags *genericclioptions.RecordFlags + PrintFlags *genericclioptions.PrintFlags + PrintObj printers.ResourcePrinterFunc + + Selector string + All bool + Replicas int + ResourceVersion string + CurrentReplicas int + Timeout time.Duration + + Recorder genericclioptions.Recorder + builder *resource.Builder + namespace string + enforceNamespace bool + args []string + shortOutput bool + clientSet kubernetes.Interface + scaler scale.Scaler + unstructuredClientForMapping func(mapping *meta.RESTMapping) (resource.RESTClient, error) + parent string + + genericclioptions.IOStreams +} + +func NewScaleOptions(ioStreams genericclioptions.IOStreams) *ScaleOptions { + return &ScaleOptions{ + PrintFlags: genericclioptions.NewPrintFlags("scaled"), + RecordFlags: genericclioptions.NewRecordFlags(), + CurrentReplicas: -1, + Recorder: genericclioptions.NoopRecorder{}, + IOStreams: ioStreams, + } +} + +// NewCmdScale returns a cobra command with the appropriate configuration and flags to run scale +func NewCmdScale(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewScaleOptions(ioStreams) + + validArgs := []string{"deployment", "replicaset", "replicationcontroller", "statefulset"} + + cmd := &cobra.Command{ + Use: "scale [--resource-version=version] [--current-replicas=count] --replicas=COUNT (-f FILENAME | TYPE NAME)", + DisableFlagsInUseLine: true, + Short: i18n.T("Set a new size for a Deployment, ReplicaSet, Replication Controller, or Job"), + Long: scaleLong, + Example: scaleExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate(cmd)) + cmdutil.CheckErr(o.RunScale()) + }, + ValidArgs: validArgs, + } + + o.RecordFlags.AddFlags(cmd) + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().BoolVar(&o.All, "all", o.All, "Select all resources in the namespace of the specified resource types") + cmd.Flags().StringVar(&o.ResourceVersion, "resource-version", o.ResourceVersion, i18n.T("Precondition for resource version. Requires that the current resource version match this value in order to scale.")) + cmd.Flags().IntVar(&o.CurrentReplicas, "current-replicas", o.CurrentReplicas, "Precondition for current size. Requires that the current size of the resource match this value in order to scale.") + cmd.Flags().IntVar(&o.Replicas, "replicas", o.Replicas, "The new desired number of replicas. Required.") + cmd.MarkFlagRequired("replicas") + cmd.Flags().DurationVar(&o.Timeout, "timeout", 0, "The length of time to wait before giving up on a scale operation, zero means don't wait. Any other values should contain a corresponding time unit (e.g. 1s, 2m, 3h).") + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, "identifying the resource to set a new size") + return cmd +} + +func (o *ScaleOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = printer.PrintObj + + o.namespace, o.enforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + o.builder = f.NewBuilder() + o.args = args + o.shortOutput = cmdutil.GetFlagString(cmd, "output") == "name" + o.clientSet, err = f.KubernetesClientSet() + if err != nil { + return err + } + o.scaler, err = scaler(f) + if err != nil { + return err + } + o.unstructuredClientForMapping = f.UnstructuredClientForMapping + o.parent = cmd.Parent().Name() + + return nil +} + +func (o *ScaleOptions) Validate(cmd *cobra.Command) error { + if o.Replicas < 0 { + return fmt.Errorf("The --replicas=COUNT flag is required, and COUNT must be greater than or equal to 0") + } + + return nil +} + +// RunScale executes the scaling +func (o *ScaleOptions) RunScale() error { + r := o.builder. + Unstructured(). + ContinueOnError(). + NamespaceParam(o.namespace).DefaultNamespace(). + FilenameParam(o.enforceNamespace, &o.FilenameOptions). + ResourceTypeOrNameArgs(o.All, o.args...). + Flatten(). + LabelSelectorParam(o.Selector). + Do() + err := r.Err() + if err != nil { + return err + } + + infos := []*resource.Info{} + err = r.Visit(func(info *resource.Info, err error) error { + if err == nil { + infos = append(infos, info) + } + return nil + }) + + if len(o.ResourceVersion) != 0 && len(infos) > 1 { + return fmt.Errorf("cannot use --resource-version with multiple resources") + } + + // only set a precondition if the user has requested one. A nil precondition means we can do a blind update, so + // we avoid a Scale GET that may or may not succeed + var precondition *scale.ScalePrecondition + if o.CurrentReplicas != -1 || len(o.ResourceVersion) > 0 { + precondition = &scale.ScalePrecondition{Size: o.CurrentReplicas, ResourceVersion: o.ResourceVersion} + } + retry := scale.NewRetryParams(1*time.Second, 5*time.Minute) + + var waitForReplicas *scale.RetryParams + if o.Timeout != 0 { + waitForReplicas = scale.NewRetryParams(1*time.Second, timeout) + } + + counter := 0 + err = r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + mapping := info.ResourceMapping() + if err := o.scaler.Scale(info.Namespace, info.Name, uint(o.Replicas), precondition, retry, waitForReplicas, mapping.Resource.GroupResource()); err != nil { + return err + } + + // if the recorder makes a change, compute and create another patch + if mergePatch, err := o.Recorder.MakeRecordMergePatch(info.Object); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } else if len(mergePatch) > 0 { + client, err := o.unstructuredClientForMapping(mapping) + if err != nil { + return err + } + helper := resource.NewHelper(client, mapping) + if _, err := helper.Patch(info.Namespace, info.Name, types.MergePatchType, mergePatch, nil); err != nil { + klog.V(4).Infof("error recording reason: %v", err) + } + } + + counter++ + return o.PrintObj(info.Object, o.Out) + }) + if err != nil { + return err + } + if counter == 0 { + return fmt.Errorf("no objects passed to scale") + } + return nil +} + +func scaler(f cmdutil.Factory) (scale.Scaler, error) { + scalesGetter, err := cmdutil.ScaleClientFn(f) + if err != nil { + return nil, err + } + + return scale.NewScaler(scalesGetter), nil +} diff --git a/pkg/cmd/set/env/doc.go b/pkg/cmd/set/env/doc.go new file mode 100644 index 000000000..25e4c04a7 --- /dev/null +++ b/pkg/cmd/set/env/doc.go @@ -0,0 +1,18 @@ +/* +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 env provides functions to incorporate environment variables into set env. +package env diff --git a/pkg/cmd/set/env/env_parse.go b/pkg/cmd/set/env/env_parse.go new file mode 100644 index 000000000..5e2e52908 --- /dev/null +++ b/pkg/cmd/set/env/env_parse.go @@ -0,0 +1,137 @@ +/* +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 env + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strings" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +var argumentEnvironment = regexp.MustCompile("(?ms)^(.+)\\=(.*)$") +var validArgumentEnvironment = regexp.MustCompile("(?ms)^(\\w+)\\=(.*)$") + +// IsEnvironmentArgument checks whether a string is an environment argument, that is, whether it matches the "anycharacters=anycharacters" pattern. +func IsEnvironmentArgument(s string) bool { + return argumentEnvironment.MatchString(s) +} + +// IsValidEnvironmentArgument checks whether a string is a valid environment argument, that is, whether it matches the "wordcharacters=anycharacters" pattern. Word characters can be letters, numbers, and underscores. +func IsValidEnvironmentArgument(s string) bool { + return validArgumentEnvironment.MatchString(s) +} + +// SplitEnvironmentFromResources separates resources from environment arguments. +// Resources must come first. Arguments may have the "DASH-" syntax. +func SplitEnvironmentFromResources(args []string) (resources, envArgs []string, ok bool) { + first := true + for _, s := range args { + // this method also has to understand env removal syntax, i.e. KEY- + isEnv := IsEnvironmentArgument(s) || strings.HasSuffix(s, "-") + switch { + case first && isEnv: + first = false + fallthrough + case !first && isEnv: + envArgs = append(envArgs, s) + case first && !isEnv: + resources = append(resources, s) + case !first && !isEnv: + return nil, nil, false + } + } + return resources, envArgs, true +} + +// parseIntoEnvVar parses the list of key-value pairs into kubernetes EnvVar. +// envVarType is for making errors more specific to user intentions. +func parseIntoEnvVar(spec []string, defaultReader io.Reader, envVarType string) ([]v1.EnvVar, []string, error) { + env := []v1.EnvVar{} + exists := sets.NewString() + var remove []string + for _, envSpec := range spec { + switch { + case !IsValidEnvironmentArgument(envSpec) && !strings.HasSuffix(envSpec, "-"): + return nil, nil, fmt.Errorf("%ss must be of the form key=value and can only contain letters, numbers, and underscores", envVarType) + case envSpec == "-": + if defaultReader == nil { + return nil, nil, fmt.Errorf("when '-' is used, STDIN must be open") + } + fileEnv, err := readEnv(defaultReader, envVarType) + if err != nil { + return nil, nil, err + } + env = append(env, fileEnv...) + case strings.Index(envSpec, "=") != -1: + parts := strings.SplitN(envSpec, "=", 2) + if len(parts) != 2 { + return nil, nil, fmt.Errorf("invalid %s: %v", envVarType, envSpec) + } + exists.Insert(parts[0]) + env = append(env, v1.EnvVar{ + Name: parts[0], + Value: parts[1], + }) + case strings.HasSuffix(envSpec, "-"): + remove = append(remove, envSpec[:len(envSpec)-1]) + default: + return nil, nil, fmt.Errorf("unknown %s: %v", envVarType, envSpec) + } + } + for _, removeLabel := range remove { + if _, found := exists[removeLabel]; found { + return nil, nil, fmt.Errorf("can not both modify and remove the same %s in the same command", envVarType) + } + } + return env, remove, nil +} + +// ParseEnv parses the elements of the first argument looking for environment variables in key=value form and, if one of those values is "-", it also scans the reader. +// The same environment variable cannot be both modified and removed in the same command. +func ParseEnv(spec []string, defaultReader io.Reader) ([]v1.EnvVar, []string, error) { + return parseIntoEnvVar(spec, defaultReader, "environment variable") +} + +func readEnv(r io.Reader, envVarType string) ([]v1.EnvVar, error) { + env := []v1.EnvVar{} + scanner := bufio.NewScanner(r) + for scanner.Scan() { + envSpec := scanner.Text() + if pos := strings.Index(envSpec, "#"); pos != -1 { + envSpec = envSpec[:pos] + } + if strings.Index(envSpec, "=") != -1 { + parts := strings.SplitN(envSpec, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid %s: %v", envVarType, envSpec) + } + env = append(env, v1.EnvVar{ + Name: parts[0], + Value: parts[1], + }) + } + } + if err := scanner.Err(); err != nil && err != io.EOF { + return nil, err + } + return env, nil +} diff --git a/pkg/cmd/set/env/env_parse_test.go b/pkg/cmd/set/env/env_parse_test.go new file mode 100644 index 000000000..5cff84a18 --- /dev/null +++ b/pkg/cmd/set/env/env_parse_test.go @@ -0,0 +1,69 @@ +/* +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 env + +import ( + "fmt" + "io" + "strings" +) + +func ExampleIsEnvironmentArgument_true() { + test := "returns=true" + fmt.Println(IsEnvironmentArgument(test)) + // Output: true +} + +func ExampleIsEnvironmentArgument_false() { + test := "returnsfalse" + fmt.Println(IsEnvironmentArgument(test)) + // Output: false +} + +func ExampleIsValidEnvironmentArgument_true() { + test := "wordcharacters=true" + fmt.Println(IsValidEnvironmentArgument(test)) + // Output: true +} + +func ExampleIsValidEnvironmentArgument_false() { + test := "not$word^characters=test" + fmt.Println(IsValidEnvironmentArgument(test)) + // Output: false +} + +func ExampleSplitEnvironmentFromResources() { + args := []string{`resource`, "ENV\\=ARG", `ONE\=MORE`, `DASH-`} + fmt.Println(SplitEnvironmentFromResources(args)) + // Output: [resource] [ENV\=ARG ONE\=MORE DASH-] true +} + +func ExampleParseEnv_good() { + r := strings.NewReader("FROM=READER") + ss := []string{"ENV=VARIABLE", "AND=ANOTHER", "REMOVE-", "-"} + fmt.Println(ParseEnv(ss, r)) + // Output: + // [{ENV VARIABLE nil} {AND ANOTHER nil} {FROM READER nil}] [REMOVE] +} + +func ExampleParseEnv_bad() { + var r io.Reader + bad := []string{"This not in the key=value format."} + fmt.Println(ParseEnv(bad, r)) + // Output: + // [] [] environment variables must be of the form key=value and can only contain letters, numbers, and underscores +} diff --git a/pkg/cmd/set/env/env_resolve.go b/pkg/cmd/set/env/env_resolve.go new file mode 100644 index 000000000..2328ef7d3 --- /dev/null +++ b/pkg/cmd/set/env/env_resolve.go @@ -0,0 +1,270 @@ +/* +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 env + +import ( + "fmt" + "math" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/client-go/kubernetes" +) + +// ResourceStore defines a new resource store data structure. +type ResourceStore struct { + SecretStore map[string]*corev1.Secret + ConfigMapStore map[string]*corev1.ConfigMap +} + +// NewResourceStore returns a pointer to a new resource store data structure. +func NewResourceStore() *ResourceStore { + return &ResourceStore{ + SecretStore: make(map[string]*corev1.Secret), + ConfigMapStore: make(map[string]*corev1.ConfigMap), + } +} + +// getSecretRefValue returns the value of a secret in the supplied namespace +func getSecretRefValue(client kubernetes.Interface, namespace string, store *ResourceStore, secretSelector *corev1.SecretKeySelector) (string, error) { + secret, ok := store.SecretStore[secretSelector.Name] + if !ok { + var err error + secret, err = client.CoreV1().Secrets(namespace).Get(secretSelector.Name, metav1.GetOptions{}) + if err != nil { + return "", err + } + store.SecretStore[secretSelector.Name] = secret + } + if data, ok := secret.Data[secretSelector.Key]; ok { + return string(data), nil + } + return "", fmt.Errorf("key %s not found in secret %s", secretSelector.Key, secretSelector.Name) + +} + +// getConfigMapRefValue returns the value of a configmap in the supplied namespace +func getConfigMapRefValue(client kubernetes.Interface, namespace string, store *ResourceStore, configMapSelector *corev1.ConfigMapKeySelector) (string, error) { + configMap, ok := store.ConfigMapStore[configMapSelector.Name] + if !ok { + var err error + configMap, err = client.CoreV1().ConfigMaps(namespace).Get(configMapSelector.Name, metav1.GetOptions{}) + if err != nil { + return "", err + } + store.ConfigMapStore[configMapSelector.Name] = configMap + } + if data, ok := configMap.Data[configMapSelector.Key]; ok { + return string(data), nil + } + return "", fmt.Errorf("key %s not found in config map %s", configMapSelector.Key, configMapSelector.Name) +} + +// getFieldRef returns the value of the supplied path in the given object +func getFieldRef(obj runtime.Object, from *corev1.EnvVarSource) (string, error) { + return extractFieldPathAsString(obj, from.FieldRef.FieldPath) +} + +// extractFieldPathAsString extracts the field from the given object +// and returns it as a string. The object must be a pointer to an +// API type. +func extractFieldPathAsString(obj interface{}, fieldPath string) (string, error) { + accessor, err := meta.Accessor(obj) + if err != nil { + return "", nil + } + + if path, subscript, ok := splitMaybeSubscriptedPath(fieldPath); ok { + switch path { + case "metadata.annotations": + if errs := validation.IsQualifiedName(strings.ToLower(subscript)); len(errs) != 0 { + return "", fmt.Errorf("invalid key subscript in %s: %s", fieldPath, strings.Join(errs, ";")) + } + return accessor.GetAnnotations()[subscript], nil + case "metadata.labels": + if errs := validation.IsQualifiedName(subscript); len(errs) != 0 { + return "", fmt.Errorf("invalid key subscript in %s: %s", fieldPath, strings.Join(errs, ";")) + } + return accessor.GetLabels()[subscript], nil + default: + return "", fmt.Errorf("fieldPath %q does not support subscript", fieldPath) + } + } + + switch fieldPath { + case "metadata.annotations": + return formatMap(accessor.GetAnnotations()), nil + case "metadata.labels": + return formatMap(accessor.GetLabels()), nil + case "metadata.name": + return accessor.GetName(), nil + case "metadata.namespace": + return accessor.GetNamespace(), nil + case "metadata.uid": + return string(accessor.GetUID()), nil + } + + return "", fmt.Errorf("unsupported fieldPath: %v", fieldPath) +} + +// splitMaybeSubscriptedPath checks whether the specified fieldPath is +// subscripted, and +// - if yes, this function splits the fieldPath into path and subscript, and +// returns (path, subscript, true). +// - if no, this function returns (fieldPath, "", false). +// +// Example inputs and outputs: +// - "metadata.annotations['myKey']" --> ("metadata.annotations", "myKey", true) +// - "metadata.annotations['a[b]c']" --> ("metadata.annotations", "a[b]c", true) +// - "metadata.labels['']" --> ("metadata.labels", "", true) +// - "metadata.labels" --> ("metadata.labels", "", false) +func splitMaybeSubscriptedPath(fieldPath string) (string, string, bool) { + if !strings.HasSuffix(fieldPath, "']") { + return fieldPath, "", false + } + s := strings.TrimSuffix(fieldPath, "']") + parts := strings.SplitN(s, "['", 2) + if len(parts) < 2 { + return fieldPath, "", false + } + if len(parts[0]) == 0 { + return fieldPath, "", false + } + return parts[0], parts[1], true +} + +// formatMap formats map[string]string to a string. +func formatMap(m map[string]string) (fmtStr string) { + // output with keys in sorted order to provide stable output + keys := sets.NewString() + for key := range m { + keys.Insert(key) + } + for _, key := range keys.List() { + fmtStr += fmt.Sprintf("%v=%q\n", key, m[key]) + } + fmtStr = strings.TrimSuffix(fmtStr, "\n") + + return +} + +// getResourceFieldRef returns the value of a resource in the given container +func getResourceFieldRef(from *corev1.EnvVarSource, container *corev1.Container) (string, error) { + return extractContainerResourceValue(from.ResourceFieldRef, container) +} + +// ExtractContainerResourceValue extracts the value of a resource +// in an already known container +func extractContainerResourceValue(fs *corev1.ResourceFieldSelector, container *corev1.Container) (string, error) { + divisor := resource.Quantity{} + if divisor.Cmp(fs.Divisor) == 0 { + divisor = resource.MustParse("1") + } else { + divisor = fs.Divisor + } + + switch fs.Resource { + case "limits.cpu": + return convertResourceCPUToString(container.Resources.Limits.Cpu(), divisor) + case "limits.memory": + return convertResourceMemoryToString(container.Resources.Limits.Memory(), divisor) + case "limits.ephemeral-storage": + return convertResourceEphemeralStorageToString(container.Resources.Limits.StorageEphemeral(), divisor) + case "requests.cpu": + return convertResourceCPUToString(container.Resources.Requests.Cpu(), divisor) + case "requests.memory": + return convertResourceMemoryToString(container.Resources.Requests.Memory(), divisor) + case "requests.ephemeral-storage": + return convertResourceEphemeralStorageToString(container.Resources.Requests.StorageEphemeral(), divisor) + } + + return "", fmt.Errorf("Unsupported container resource : %v", fs.Resource) +} + +// convertResourceCPUToString converts cpu value to the format of divisor and returns +// ceiling of the value. +func convertResourceCPUToString(cpu *resource.Quantity, divisor resource.Quantity) (string, error) { + c := int64(math.Ceil(float64(cpu.MilliValue()) / float64(divisor.MilliValue()))) + return strconv.FormatInt(c, 10), nil +} + +// convertResourceMemoryToString converts memory value to the format of divisor and returns +// ceiling of the value. +func convertResourceMemoryToString(memory *resource.Quantity, divisor resource.Quantity) (string, error) { + m := int64(math.Ceil(float64(memory.Value()) / float64(divisor.Value()))) + return strconv.FormatInt(m, 10), nil +} + +// convertResourceEphemeralStorageToString converts ephemeral storage value to the format of divisor and returns +// ceiling of the value. +func convertResourceEphemeralStorageToString(ephemeralStorage *resource.Quantity, divisor resource.Quantity) (string, error) { + m := int64(math.Ceil(float64(ephemeralStorage.Value()) / float64(divisor.Value()))) + return strconv.FormatInt(m, 10), nil +} + +// GetEnvVarRefValue returns the value referenced by the supplied EnvVarSource given the other supplied information. +func GetEnvVarRefValue(kc kubernetes.Interface, ns string, store *ResourceStore, from *corev1.EnvVarSource, obj runtime.Object, c *corev1.Container) (string, error) { + if from.SecretKeyRef != nil { + return getSecretRefValue(kc, ns, store, from.SecretKeyRef) + } + + if from.ConfigMapKeyRef != nil { + return getConfigMapRefValue(kc, ns, store, from.ConfigMapKeyRef) + } + + if from.FieldRef != nil { + return getFieldRef(obj, from) + } + + if from.ResourceFieldRef != nil { + return getResourceFieldRef(from, c) + } + + return "", fmt.Errorf("invalid valueFrom") +} + +// GetEnvVarRefString returns a text description of whichever field is set within the supplied EnvVarSource argument. +func GetEnvVarRefString(from *corev1.EnvVarSource) string { + if from.ConfigMapKeyRef != nil { + return fmt.Sprintf("configmap %s, key %s", from.ConfigMapKeyRef.Name, from.ConfigMapKeyRef.Key) + } + + if from.SecretKeyRef != nil { + return fmt.Sprintf("secret %s, key %s", from.SecretKeyRef.Name, from.SecretKeyRef.Key) + } + + if from.FieldRef != nil { + return fmt.Sprintf("field path %s", from.FieldRef.FieldPath) + } + + if from.ResourceFieldRef != nil { + containerPrefix := "" + if from.ResourceFieldRef.ContainerName != "" { + containerPrefix = fmt.Sprintf("%s/", from.ResourceFieldRef.ContainerName) + } + return fmt.Sprintf("resource field %s%s", containerPrefix, from.ResourceFieldRef.Resource) + } + + return "invalid valueFrom" +} diff --git a/pkg/cmd/set/helper.go b/pkg/cmd/set/helper.go new file mode 100644 index 000000000..72cc77560 --- /dev/null +++ b/pkg/cmd/set/helper.go @@ -0,0 +1,156 @@ +/* +Copyright 2016 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 set + +import ( + "strings" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/cli-runtime/pkg/resource" +) + +// selectContainers allows one or more containers to be matched against a string or wildcard +func selectContainers(containers []v1.Container, spec string) ([]*v1.Container, []*v1.Container) { + out := []*v1.Container{} + skipped := []*v1.Container{} + for i, c := range containers { + if selectString(c.Name, spec) { + out = append(out, &containers[i]) + } else { + skipped = append(skipped, &containers[i]) + } + } + return out, skipped +} + +// selectString returns true if the provided string matches spec, where spec is a string with +// a non-greedy '*' wildcard operator. +// TODO: turn into a regex and handle greedy matches and backtracking. +func selectString(s, spec string) bool { + if spec == "*" { + return true + } + if !strings.Contains(spec, "*") { + return s == spec + } + + pos := 0 + match := true + parts := strings.Split(spec, "*") + for i, part := range parts { + if len(part) == 0 { + continue + } + next := strings.Index(s[pos:], part) + switch { + // next part not in string + case next < pos: + fallthrough + // first part does not match start of string + case i == 0 && pos != 0: + fallthrough + // last part does not exactly match remaining part of string + case i == (len(parts)-1) && len(s) != (len(part)+next): + match = false + break + default: + pos = next + } + } + return match +} + +// Patch represents the result of a mutation to an object. +type Patch struct { + Info *resource.Info + Err error + + Before []byte + After []byte + Patch []byte +} + +// PatchFn is a function type that accepts an info object and returns a byte slice. +// Implementations of PatchFn should update the object and return it encoded. +type PatchFn func(runtime.Object) ([]byte, error) + +// CalculatePatch calls the mutation function on the provided info object, and generates a strategic merge patch for +// the changes in the object. Encoder must be able to encode the info into the appropriate destination type. +// This function returns whether the mutation function made any change in the original object. +func CalculatePatch(patch *Patch, encoder runtime.Encoder, mutateFn PatchFn) bool { + patch.Before, patch.Err = runtime.Encode(encoder, patch.Info.Object) + patch.After, patch.Err = mutateFn(patch.Info.Object) + if patch.Err != nil { + return true + } + if patch.After == nil { + return false + } + + patch.Patch, patch.Err = strategicpatch.CreateTwoWayMergePatch(patch.Before, patch.After, patch.Info.Object) + return true +} + +// CalculatePatches calculates patches on each provided info object. If the provided mutateFn +// makes no change in an object, the object is not included in the final list of patches. +func CalculatePatches(infos []*resource.Info, encoder runtime.Encoder, mutateFn PatchFn) []*Patch { + var patches []*Patch + for _, info := range infos { + patch := &Patch{Info: info} + if CalculatePatch(patch, encoder, mutateFn) { + patches = append(patches, patch) + } + } + return patches +} + +func findEnv(env []v1.EnvVar, name string) (v1.EnvVar, bool) { + for _, e := range env { + if e.Name == name { + return e, true + } + } + return v1.EnvVar{}, false +} + +func updateEnv(existing []v1.EnvVar, env []v1.EnvVar, remove []string) []v1.EnvVar { + out := []v1.EnvVar{} + covered := sets.NewString(remove...) + for _, e := range existing { + if covered.Has(e.Name) { + continue + } + newer, ok := findEnv(env, e.Name) + if ok { + covered.Insert(e.Name) + out = append(out, newer) + continue + } + out = append(out, e) + } + for _, e := range env { + if covered.Has(e.Name) { + continue + } + covered.Insert(e.Name) + out = append(out, e) + } + return out +} diff --git a/pkg/cmd/set/set.go b/pkg/cmd/set/set.go new file mode 100644 index 000000000..127dcb247 --- /dev/null +++ b/pkg/cmd/set/set.go @@ -0,0 +1,53 @@ +/* +Copyright 2016 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 set + +import ( + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + setLong = templates.LongDesc(` + Configure application resources + + These commands help you make changes to existing application resources.`) +) + +// NewCmdSet returns an initialized Command instance for 'set' sub command +func NewCmdSet(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "set SUBCOMMAND", + DisableFlagsInUseLine: true, + Short: i18n.T("Set specific features on objects"), + Long: setLong, + Run: cmdutil.DefaultSubCommandRun(streams.ErrOut), + } + + // add subcommands + cmd.AddCommand(NewCmdImage(f, streams)) + cmd.AddCommand(NewCmdResources(f, streams)) + cmd.AddCommand(NewCmdSelector(f, streams)) + cmd.AddCommand(NewCmdSubject(f, streams)) + cmd.AddCommand(NewCmdServiceAccount(f, streams)) + cmd.AddCommand(NewCmdEnv(f, streams)) + + return cmd +} diff --git a/pkg/cmd/set/set_env.go b/pkg/cmd/set/set_env.go new file mode 100644 index 000000000..ad18f20af --- /dev/null +++ b/pkg/cmd/set/set_env.go @@ -0,0 +1,511 @@ +/* +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 set + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strings" + + "github.com/spf13/cobra" + + "k8s.io/api/core/v1" + meta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/kubernetes" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/templates" + envutil "k8s.io/kubernetes/pkg/kubectl/cmd/set/env" +) + +var ( + validEnvNameRegexp = regexp.MustCompile("[^a-zA-Z0-9_]") + envResources = ` + pod (po), replicationcontroller (rc), deployment (deploy), daemonset (ds), job, replicaset (rs)` + + envLong = templates.LongDesc(` + Update environment variables on a pod template. + + List environment variable definitions in one or more pods, pod templates. + Add, update, or remove container environment variable definitions in one or + more pod templates (within replication controllers or deployment configurations). + View or modify the environment variable definitions on all containers in the + specified pods or pod templates, or just those that match a wildcard. + + If "--env -" is passed, environment variables can be read from STDIN using the standard env + syntax. + + Possible resources include (case insensitive): + ` + envResources) + + envExample = templates.Examples(` + # Update deployment 'registry' with a new environment variable + kubectl set env deployment/registry STORAGE_DIR=/local + + # List the environment variables defined on a deployments 'sample-build' + kubectl set env deployment/sample-build --list + + # List the environment variables defined on all pods + kubectl set env pods --all --list + + # Output modified deployment in YAML, and does not alter the object on the server + kubectl set env deployment/sample-build STORAGE_DIR=/data -o yaml + + # Update all containers in all replication controllers in the project to have ENV=prod + kubectl set env rc --all ENV=prod + + # Import environment from a secret + kubectl set env --from=secret/mysecret deployment/myapp + + # Import environment from a config map with a prefix + kubectl set env --from=configmap/myconfigmap --prefix=MYSQL_ deployment/myapp + + # Import specific keys from a config map + kubectl set env --keys=my-example-key --from=configmap/myconfigmap deployment/myapp + + # Remove the environment variable ENV from container 'c1' in all deployment configs + kubectl set env deployments --all --containers="c1" ENV- + + # Remove the environment variable ENV from a deployment definition on disk and + # update the deployment config on the server + kubectl set env -f deploy.json ENV- + + # Set some of the local shell environment into a deployment config on the server + env | grep RAILS_ | kubectl set env -e - deployment/registry`) +) + +// EnvOptions holds values for 'set env' command-lone options +type EnvOptions struct { + PrintFlags *genericclioptions.PrintFlags + resource.FilenameOptions + + EnvParams []string + All bool + Resolve bool + List bool + Local bool + Overwrite bool + ContainerSelector string + Selector string + From string + Prefix string + Keys []string + + PrintObj printers.ResourcePrinterFunc + + envArgs []string + resources []string + output string + dryRun bool + builder func() *resource.Builder + updatePodSpecForObject polymorphichelpers.UpdatePodSpecForObjectFunc + namespace string + enforceNamespace bool + clientset *kubernetes.Clientset + + genericclioptions.IOStreams +} + +// NewEnvOptions returns an EnvOptions indicating all containers in the selected +// pod templates are selected by default and allowing environment to be overwritten +func NewEnvOptions(streams genericclioptions.IOStreams) *EnvOptions { + return &EnvOptions{ + PrintFlags: genericclioptions.NewPrintFlags("env updated").WithTypeSetter(scheme.Scheme), + + ContainerSelector: "*", + Overwrite: true, + + IOStreams: streams, + } +} + +// NewCmdEnv implements the OpenShift cli env command +func NewCmdEnv(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewEnvOptions(streams) + cmd := &cobra.Command{ + Use: "env RESOURCE/NAME KEY_1=VAL_1 ... KEY_N=VAL_N", + DisableFlagsInUseLine: true, + Short: "Update environment variables on a pod template", + Long: envLong, + Example: fmt.Sprintf(envExample), + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunEnv()) + }, + } + usage := "the resource to update the env" + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + cmd.Flags().StringVarP(&o.ContainerSelector, "containers", "c", o.ContainerSelector, "The names of containers in the selected pod templates to change - may use wildcards") + cmd.Flags().StringVarP(&o.From, "from", "", "", "The name of a resource from which to inject environment variables") + cmd.Flags().StringVarP(&o.Prefix, "prefix", "", "", "Prefix to append to variable names") + cmd.Flags().StringArrayVarP(&o.EnvParams, "env", "e", o.EnvParams, "Specify a key-value pair for an environment variable to set into each container.") + cmd.Flags().StringSliceVarP(&o.Keys, "keys", "", o.Keys, "Comma-separated list of keys to import from specified resource") + cmd.Flags().BoolVar(&o.List, "list", o.List, "If true, display the environment and any changes in the standard format. this flag will removed when we have kubectl view env.") + cmd.Flags().BoolVar(&o.Resolve, "resolve", o.Resolve, "If true, show secret or configmap references when listing variables") + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on") + cmd.Flags().BoolVar(&o.Local, "local", o.Local, "If true, set env will NOT contact api-server but run locally.") + cmd.Flags().BoolVar(&o.All, "all", o.All, "If true, select all resources in the namespace of the specified resource types") + cmd.Flags().BoolVar(&o.Overwrite, "overwrite", o.Overwrite, "If true, allow environment to be overwritten, otherwise reject updates that overwrite existing environment.") + + o.PrintFlags.AddFlags(cmd) + + cmdutil.AddDryRunFlag(cmd) + return cmd +} + +func validateNoOverwrites(existing []v1.EnvVar, env []v1.EnvVar) error { + for _, e := range env { + if current, exists := findEnv(existing, e.Name); exists && current.Value != e.Value { + return fmt.Errorf("'%s' already has a value (%s), and --overwrite is false", current.Name, current.Value) + } + } + return nil +} + +func keyToEnvName(key string) string { + return strings.ToUpper(validEnvNameRegexp.ReplaceAllString(key, "_")) +} + +func contains(key string, keyList []string) bool { + if len(keyList) == 0 { + return true + } + + for _, k := range keyList { + if k == key { + return true + } + } + return false +} + +// Complete completes all required options +func (o *EnvOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + if o.All && len(o.Selector) > 0 { + return fmt.Errorf("cannot set --all and --selector at the same time") + } + ok := false + o.resources, o.envArgs, ok = envutil.SplitEnvironmentFromResources(args) + if !ok { + return fmt.Errorf("all resources must be specified before environment changes: %s", strings.Join(args, " ")) + } + + o.updatePodSpecForObject = polymorphichelpers.UpdatePodSpecForObjectFn + o.output = cmdutil.GetFlagString(cmd, "output") + o.dryRun = cmdutil.GetDryRunFlag(cmd) + + if o.dryRun { + // TODO(juanvallejo): This can be cleaned up even further by creating + // a PrintFlags struct that binds the --dry-run flag, and whose + // ToPrinter method returns a printer that understands how to print + // this success message. + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = printer.PrintObj + + o.clientset, err = f.KubernetesClientSet() + if err != nil { + return err + } + o.namespace, o.enforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + o.builder = f.NewBuilder + + return nil +} + +// Validate makes sure provided values for EnvOptions are valid +func (o *EnvOptions) Validate() error { + if len(o.Filenames) == 0 && len(o.resources) < 1 { + return fmt.Errorf("one or more resources must be specified as or /") + } + if o.List && len(o.output) > 0 { + return fmt.Errorf("--list and --output may not be specified together") + } + if len(o.Keys) > 0 && len(o.From) == 0 { + return fmt.Errorf("when specifying --keys, a configmap or secret must be provided with --from") + } + return nil +} + +// RunEnv contains all the necessary functionality for the OpenShift cli env command +func (o *EnvOptions) RunEnv() error { + env, remove, err := envutil.ParseEnv(append(o.EnvParams, o.envArgs...), o.In) + if err != nil { + return err + } + + if len(o.From) != 0 { + b := o.builder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + LocalParam(o.Local). + ContinueOnError(). + NamespaceParam(o.namespace).DefaultNamespace(). + FilenameParam(o.enforceNamespace, &o.FilenameOptions). + Flatten() + + if !o.Local { + b = b. + LabelSelectorParam(o.Selector). + ResourceTypeOrNameArgs(o.All, o.From). + Latest() + } + + infos, err := b.Do().Infos() + if err != nil { + return err + } + + for _, info := range infos { + switch from := info.Object.(type) { + case *v1.Secret: + for key := range from.Data { + if contains(key, o.Keys) { + envVar := v1.EnvVar{ + Name: keyToEnvName(key), + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: from.Name, + }, + Key: key, + }, + }, + } + env = append(env, envVar) + } + } + case *v1.ConfigMap: + for key := range from.Data { + if contains(key, o.Keys) { + envVar := v1.EnvVar{ + Name: keyToEnvName(key), + ValueFrom: &v1.EnvVarSource{ + ConfigMapKeyRef: &v1.ConfigMapKeySelector{ + LocalObjectReference: v1.LocalObjectReference{ + Name: from.Name, + }, + Key: key, + }, + }, + } + env = append(env, envVar) + } + } + default: + return fmt.Errorf("unsupported resource specified in --from") + } + } + } + + if len(o.Prefix) != 0 { + for i := range env { + env[i].Name = fmt.Sprintf("%s%s", o.Prefix, env[i].Name) + } + } + + b := o.builder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + LocalParam(o.Local). + ContinueOnError(). + NamespaceParam(o.namespace).DefaultNamespace(). + FilenameParam(o.enforceNamespace, &o.FilenameOptions). + Flatten() + + if !o.Local { + b.LabelSelectorParam(o.Selector). + ResourceTypeOrNameArgs(o.All, o.resources...). + Latest() + } + + infos, err := b.Do().Infos() + if err != nil { + return err + } + patches := CalculatePatches(infos, scheme.DefaultJSONEncoder(), func(obj runtime.Object) ([]byte, error) { + _, err := o.updatePodSpecForObject(obj, func(spec *v1.PodSpec) error { + resolutionErrorsEncountered := false + containers, _ := selectContainers(spec.Containers, o.ContainerSelector) + objName, err := meta.NewAccessor().Name(obj) + if err != nil { + return err + } + + gvks, _, err := scheme.Scheme.ObjectKinds(obj) + if err != nil { + return err + } + objKind := obj.GetObjectKind().GroupVersionKind().Kind + if len(objKind) == 0 { + for _, gvk := range gvks { + if len(gvk.Kind) == 0 { + continue + } + if len(gvk.Version) == 0 || gvk.Version == runtime.APIVersionInternal { + continue + } + + objKind = gvk.Kind + break + } + } + + if len(containers) == 0 { + if gvks, _, err := scheme.Scheme.ObjectKinds(obj); err == nil { + objKind := obj.GetObjectKind().GroupVersionKind().Kind + if len(objKind) == 0 { + for _, gvk := range gvks { + if len(gvk.Kind) == 0 { + continue + } + if len(gvk.Version) == 0 || gvk.Version == runtime.APIVersionInternal { + continue + } + + objKind = gvk.Kind + break + } + } + + fmt.Fprintf(o.ErrOut, "warning: %s/%s does not have any containers matching %q\n", objKind, objName, o.ContainerSelector) + } + return nil + } + for _, c := range containers { + if !o.Overwrite { + if err := validateNoOverwrites(c.Env, env); err != nil { + return err + } + } + + c.Env = updateEnv(c.Env, env, remove) + if o.List { + resolveErrors := map[string][]string{} + store := envutil.NewResourceStore() + + fmt.Fprintf(o.Out, "# %s %s, container %s\n", objKind, objName, c.Name) + for _, env := range c.Env { + // Print the simple value + if env.ValueFrom == nil { + fmt.Fprintf(o.Out, "%s=%s\n", env.Name, env.Value) + continue + } + + // Print the reference version + if !o.Resolve { + fmt.Fprintf(o.Out, "# %s from %s\n", env.Name, envutil.GetEnvVarRefString(env.ValueFrom)) + continue + } + + value, err := envutil.GetEnvVarRefValue(o.clientset, o.namespace, store, env.ValueFrom, obj, c) + // Print the resolved value + if err == nil { + fmt.Fprintf(o.Out, "%s=%s\n", env.Name, value) + continue + } + + // Print the reference version and save the resolve error + fmt.Fprintf(o.Out, "# %s from %s\n", env.Name, envutil.GetEnvVarRefString(env.ValueFrom)) + errString := err.Error() + resolveErrors[errString] = append(resolveErrors[errString], env.Name) + resolutionErrorsEncountered = true + } + + // Print any resolution errors + errs := []string{} + for err, vars := range resolveErrors { + sort.Strings(vars) + errs = append(errs, fmt.Sprintf("error retrieving reference for %s: %v", strings.Join(vars, ", "), err)) + } + sort.Strings(errs) + for _, err := range errs { + fmt.Fprintln(o.ErrOut, err) + } + } + } + if resolutionErrorsEncountered { + return errors.New("failed to retrieve valueFrom references") + } + return nil + }) + + if err == nil { + return runtime.Encode(scheme.DefaultJSONEncoder(), obj) + } + return nil, err + }) + + if o.List { + return nil + } + + allErrs := []error{} + + for _, patch := range patches { + info := patch.Info + if patch.Err != nil { + name := info.ObjectName() + allErrs = append(allErrs, fmt.Errorf("error: %s %v\n", name, patch.Err)) + continue + } + + // no changes + if string(patch.Patch) == "{}" || len(patch.Patch) == 0 { + continue + } + + if o.Local || o.dryRun { + if err := o.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + continue + } + + actual, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch.Patch, nil) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to patch env update to pod template: %v", err)) + continue + } + + // make sure arguments to set or replace environment variables are set + // before returning a successful message + if len(env) == 0 && len(o.envArgs) == 0 { + return fmt.Errorf("at least one environment variable must be provided") + } + + if err := o.PrintObj(actual, o.Out); err != nil { + allErrs = append(allErrs, err) + } + } + return utilerrors.NewAggregate(allErrs) +} diff --git a/pkg/cmd/set/set_env_test.go b/pkg/cmd/set/set_env_test.go new file mode 100644 index 000000000..2881245a4 --- /dev/null +++ b/pkg/cmd/set/set_env_test.go @@ -0,0 +1,668 @@ +/* +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 set + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + appsv1beta1 "k8s.io/api/apps/v1beta1" + appsv1beta2 "k8s.io/api/apps/v1beta2" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestSetEnvLocal(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: ""}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.ClientConfigVal = &restclient.Config{ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Version: ""}}} + outputFormat := "name" + + streams, _, buf, bufErr := genericclioptions.NewTestIOStreams() + opts := NewEnvOptions(streams) + opts.PrintFlags = genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme) + opts.FilenameOptions = resource.FilenameOptions{ + Filenames: []string{"../../../../test/e2e/testing-manifests/statefulset/cassandra/controller.yaml"}, + } + opts.Local = true + + err := opts.Complete(tf, NewCmdEnv(tf, streams), []string{"env=prod"}) + assert.NoError(t, err) + err = opts.Validate() + assert.NoError(t, err) + err = opts.RunEnv() + assert.NoError(t, err) + if bufErr.Len() > 0 { + t.Errorf("unexpected error: %s", string(bufErr.String())) + } + if !strings.Contains(buf.String(), "replicationcontroller/cassandra") { + t.Errorf("did not set env: %s", buf.String()) + } +} + +func TestSetEnvLocalNamespace(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: ""}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.ClientConfigVal = &restclient.Config{ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Version: ""}}} + outputFormat := "yaml" + + streams, _, buf, bufErr := genericclioptions.NewTestIOStreams() + opts := NewEnvOptions(streams) + opts.PrintFlags = genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme) + opts.FilenameOptions = resource.FilenameOptions{ + Filenames: []string{"../../../../test/fixtures/pkg/kubectl/cmd/set/namespaced-resource.yaml"}, + } + opts.Local = true + + err := opts.Complete(tf, NewCmdEnv(tf, streams), []string{"env=prod"}) + assert.NoError(t, err) + err = opts.Validate() + assert.NoError(t, err) + err = opts.RunEnv() + assert.NoError(t, err) + if bufErr.Len() > 0 { + t.Errorf("unexpected error: %s", string(bufErr.String())) + } + if !strings.Contains(buf.String(), "namespace: existing-ns") { + t.Errorf("did not set env: %s", buf.String()) + } +} + +func TestSetMultiResourcesEnvLocal(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: ""}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.ClientConfigVal = &restclient.Config{ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Version: ""}}} + + outputFormat := "name" + streams, _, buf, bufErr := genericclioptions.NewTestIOStreams() + opts := NewEnvOptions(streams) + opts.PrintFlags = genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme) + opts.FilenameOptions = resource.FilenameOptions{ + Filenames: []string{"../../../../test/fixtures/pkg/kubectl/cmd/set/multi-resource-yaml.yaml"}, + } + opts.Local = true + + err := opts.Complete(tf, NewCmdEnv(tf, streams), []string{"env=prod"}) + assert.NoError(t, err) + err = opts.Validate() + assert.NoError(t, err) + err = opts.RunEnv() + assert.NoError(t, err) + if bufErr.Len() > 0 { + t.Errorf("unexpected error: %s", string(bufErr.String())) + } + expectedOut := "replicationcontroller/first-rc\nreplicationcontroller/second-rc\n" + if buf.String() != expectedOut { + t.Errorf("expected out:\n%s\nbut got:\n%s", expectedOut, buf.String()) + } +} + +func TestSetEnvRemote(t *testing.T) { + inputs := []struct { + name string + object runtime.Object + groupVersion schema.GroupVersion + path string + args []string + }{ + { + name: "test extensions.v1beta1 replicaset", + object: &extensionsv1beta1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: extensionsv1beta1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", "env=prod"}, + }, + { + name: "test apps.v1beta2 replicaset", + object: &appsv1beta2.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", "env=prod"}, + }, + { + name: "test appsv1 replicaset", + object: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", "env=prod"}, + }, + { + name: "test extensions.v1beta1 daemonset", + object: &extensionsv1beta1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: extensionsv1beta1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx", "env=prod"}, + }, + { + name: "test appsv1beta2 daemonset", + object: &appsv1beta2.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx", "env=prod"}, + }, + { + name: "test appsv1 daemonset", + object: &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx", "env=prod"}, + }, + { + name: "test extensions.v1beta1 deployment", + object: &extensionsv1beta1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: extensionsv1beta1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", "env=prod"}, + }, + { + name: "test appsv1beta1 deployment", + object: &appsv1beta1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", "env=prod"}, + }, + { + name: "test appsv1beta2 deployment", + object: &appsv1beta2.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", "env=prod"}, + }, + { + name: "test appsv1 deployment", + object: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", "env=prod"}, + }, + { + name: "test appsv1beta1 statefulset", + object: &appsv1beta1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx", "env=prod"}, + }, + { + name: "test appsv1beta2 statefulset", + object: &appsv1beta2.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx", "env=prod"}, + }, + { + name: "test appsv1 statefulset", + object: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx", "env=prod"}, + }, + { + name: "test batchv1 Job", + object: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: batchv1.SchemeGroupVersion, + path: "/namespaces/test/jobs/nginx", + args: []string{"job", "nginx", "env=prod"}, + }, + { + name: "test corev1 replication controller", + object: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: corev1.ReplicationControllerSpec{ + Template: &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: corev1.SchemeGroupVersion, + path: "/namespaces/test/replicationcontrollers/nginx", + args: []string{"replicationcontroller", "nginx", "env=prod"}, + }, + } + for _, input := range inputs { + t.Run(input.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: input.groupVersion, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == input.path && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(input.object)}, nil + case p == input.path && m == http.MethodPatch: + stream, err := req.GetBody() + if err != nil { + return nil, err + } + bytes, err := ioutil.ReadAll(stream) + if err != nil { + return nil, err + } + assert.Contains(t, string(bytes), `"value":`+`"`+"prod"+`"`, fmt.Sprintf("env not updated for %#v", input.object)) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(input.object)}, nil + default: + t.Errorf("%s: unexpected request: %s %#v\n%#v", "image", req.Method, req.URL, req) + return nil, fmt.Errorf("unexpected request") + } + }), + } + + outputFormat := "yaml" + streams := genericclioptions.NewTestIOStreamsDiscard() + opts := NewEnvOptions(streams) + opts.PrintFlags = genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme) + opts.Local = false + opts.IOStreams = streams + err := opts.Complete(tf, NewCmdEnv(tf, streams), input.args) + assert.NoError(t, err) + err = opts.RunEnv() + assert.NoError(t, err) + }) + } +} + +func TestSetEnvFromResource(t *testing.T) { + mockConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "testconfigmap"}, + Data: map[string]string{ + "env": "prod", + "test-key": "testValue", + "test-key-two": "testValueTwo", + }, + } + + mockSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "testsecret"}, + Data: map[string][]byte{ + "env": []byte("prod"), + "test-key": []byte("testValue"), + "test-key-two": []byte("testValueTwo"), + }, + } + + inputs := []struct { + name string + args []string + from string + keys []string + assertIncludes []string + assertExcludes []string + }{ + { + name: "test from configmap", + args: []string{"deployment", "nginx"}, + from: "configmap/testconfigmap", + keys: []string{}, + assertIncludes: []string{ + `{"name":"ENV","valueFrom":{"configMapKeyRef":{"key":"env","name":"testconfigmap"}}}`, + `{"name":"TEST_KEY","valueFrom":{"configMapKeyRef":{"key":"test-key","name":"testconfigmap"}}}`, + `{"name":"TEST_KEY_TWO","valueFrom":{"configMapKeyRef":{"key":"test-key-two","name":"testconfigmap"}}}`, + }, + assertExcludes: []string{}, + }, + { + name: "test from secret", + args: []string{"deployment", "nginx"}, + from: "secret/testsecret", + keys: []string{}, + assertIncludes: []string{ + `{"name":"ENV","valueFrom":{"secretKeyRef":{"key":"env","name":"testsecret"}}}`, + `{"name":"TEST_KEY","valueFrom":{"secretKeyRef":{"key":"test-key","name":"testsecret"}}}`, + `{"name":"TEST_KEY_TWO","valueFrom":{"secretKeyRef":{"key":"test-key-two","name":"testsecret"}}}`, + }, + assertExcludes: []string{}, + }, + { + name: "test from configmap with keys", + args: []string{"deployment", "nginx"}, + from: "configmap/testconfigmap", + keys: []string{"env", "test-key-two"}, + assertIncludes: []string{ + `{"name":"ENV","valueFrom":{"configMapKeyRef":{"key":"env","name":"testconfigmap"}}}`, + `{"name":"TEST_KEY_TWO","valueFrom":{"configMapKeyRef":{"key":"test-key-two","name":"testconfigmap"}}}`, + }, + assertExcludes: []string{`{"name":"TEST_KEY","valueFrom":{"configMapKeyRef":{"key":"test-key","name":"testconfigmap"}}}`}, + }, + { + name: "test from secret with keys", + args: []string{"deployment", "nginx"}, + from: "secret/testsecret", + keys: []string{"env", "test-key-two"}, + assertIncludes: []string{ + `{"name":"ENV","valueFrom":{"secretKeyRef":{"key":"env","name":"testsecret"}}}`, + `{"name":"TEST_KEY_TWO","valueFrom":{"secretKeyRef":{"key":"test-key-two","name":"testsecret"}}}`, + }, + assertExcludes: []string{`{"name":"TEST_KEY","valueFrom":{"secretKeyRef":{"key":"test-key","name":"testsecret"}}}`}, + }, + } + + for _, input := range inputs { + mockDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + } + t.Run(input.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.ClientConfigVal = &restclient.Config{ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Version: ""}}} + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/namespaces/test/configmaps/testconfigmap" && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(mockConfigMap)}, nil + case p == "/namespaces/test/secrets/testsecret" && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(mockSecret)}, nil + case p == "/namespaces/test/deployments/nginx" && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(mockDeployment)}, nil + case p == "/namespaces/test/deployments/nginx" && m == http.MethodPatch: + stream, err := req.GetBody() + if err != nil { + return nil, err + } + bytes, err := ioutil.ReadAll(stream) + if err != nil { + return nil, err + } + for _, include := range input.assertIncludes { + assert.Contains(t, string(bytes), include) + } + for _, exclude := range input.assertExcludes { + assert.NotContains(t, string(bytes), exclude) + } + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(mockDeployment)}, nil + default: + t.Errorf("%s: unexpected request: %#v\n%#v", input.name, req.URL, req) + return nil, nil + } + }), + } + + outputFormat := "yaml" + streams := genericclioptions.NewTestIOStreamsDiscard() + opts := NewEnvOptions(streams) + opts.From = input.from + opts.Keys = input.keys + opts.PrintFlags = genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme) + opts.Local = false + opts.IOStreams = streams + err := opts.Complete(tf, NewCmdEnv(tf, streams), input.args) + assert.NoError(t, err) + err = opts.RunEnv() + assert.NoError(t, err) + }) + } +} diff --git a/pkg/cmd/set/set_image.go b/pkg/cmd/set/set_image.go new file mode 100644 index 000000000..f69bbf028 --- /dev/null +++ b/pkg/cmd/set/set_image.go @@ -0,0 +1,317 @@ +/* +Copyright 2016 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 set + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// ImageOptions is the start of the data required to perform the operation. As new fields are added, add them here instead of +// referencing the cmd.Flags() +type SetImageOptions struct { + resource.FilenameOptions + + PrintFlags *genericclioptions.PrintFlags + RecordFlags *genericclioptions.RecordFlags + + Infos []*resource.Info + Selector string + DryRun bool + All bool + Output string + Local bool + ResolveImage ImageResolver + + PrintObj printers.ResourcePrinterFunc + Recorder genericclioptions.Recorder + + UpdatePodSpecForObject polymorphichelpers.UpdatePodSpecForObjectFunc + Resources []string + ContainerImages map[string]string + + genericclioptions.IOStreams +} + +var ( + imageResources = ` + pod (po), replicationcontroller (rc), deployment (deploy), daemonset (ds), replicaset (rs)` + + imageLong = templates.LongDesc(` + Update existing container image(s) of resources. + + Possible resources include (case insensitive): + ` + imageResources) + + imageExample = templates.Examples(` + # Set a deployment's nginx container image to 'nginx:1.9.1', and its busybox container image to 'busybox'. + kubectl set image deployment/nginx busybox=busybox nginx=nginx:1.9.1 + + # Update all deployments' and rc's nginx container's image to 'nginx:1.9.1' + kubectl set image deployments,rc nginx=nginx:1.9.1 --all + + # Update image of all containers of daemonset abc to 'nginx:1.9.1' + kubectl set image daemonset abc *=nginx:1.9.1 + + # Print result (in yaml format) of updating nginx container image from local file, without hitting the server + kubectl set image -f path/to/file.yaml nginx=nginx:1.9.1 --local -o yaml`) +) + +// NewImageOptions returns an initialized SetImageOptions instance +func NewImageOptions(streams genericclioptions.IOStreams) *SetImageOptions { + return &SetImageOptions{ + PrintFlags: genericclioptions.NewPrintFlags("image updated").WithTypeSetter(scheme.Scheme), + RecordFlags: genericclioptions.NewRecordFlags(), + + Recorder: genericclioptions.NoopRecorder{}, + + IOStreams: streams, + } +} + +// NewCmdImage returns an initialized Command instance for the 'set image' sub command +func NewCmdImage(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewImageOptions(streams) + + cmd := &cobra.Command{ + Use: "image (-f FILENAME | TYPE NAME) CONTAINER_NAME_1=CONTAINER_IMAGE_1 ... CONTAINER_NAME_N=CONTAINER_IMAGE_N", + DisableFlagsInUseLine: true, + Short: i18n.T("Update image of a pod template"), + Long: imageLong, + Example: imageExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run()) + }, + } + + o.PrintFlags.AddFlags(cmd) + o.RecordFlags.AddFlags(cmd) + + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + cmd.Flags().BoolVar(&o.All, "all", o.All, "Select all resources, including uninitialized ones, in the namespace of the specified resource types") + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on, not including uninitialized ones, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().BoolVar(&o.Local, "local", o.Local, "If true, set image will NOT contact api-server but run locally.") + cmdutil.AddDryRunFlag(cmd) + cmdutil.AddIncludeUninitializedFlag(cmd) + return cmd +} + +// Complete completes all required options +func (o *SetImageOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.UpdatePodSpecForObject = polymorphichelpers.UpdatePodSpecForObjectFn + o.DryRun = cmdutil.GetDryRunFlag(cmd) + o.Output = cmdutil.GetFlagString(cmd, "output") + o.ResolveImage = resolveImageFunc + + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + + o.PrintObj = printer.PrintObj + + cmdNamespace, enforceNamespace, err := f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + o.Resources, o.ContainerImages, err = getResourcesAndImages(args) + if err != nil { + return err + } + + builder := f.NewBuilder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + LocalParam(o.Local). + ContinueOnError(). + NamespaceParam(cmdNamespace).DefaultNamespace(). + FilenameParam(enforceNamespace, &o.FilenameOptions). + Flatten() + + if !o.Local { + builder.LabelSelectorParam(o.Selector). + ResourceTypeOrNameArgs(o.All, o.Resources...). + Latest() + } else { + // if a --local flag was provided, and a resource was specified in the form + // /, fail immediately as --local cannot query the api server + // for the specified resource. + if len(o.Resources) > 0 { + return resource.LocalResourceError + } + } + + o.Infos, err = builder.Do().Infos() + if err != nil { + return err + } + + return nil +} + +// Validate makes sure provided values in SetImageOptions are valid +func (o *SetImageOptions) Validate() error { + errors := []error{} + if o.All && len(o.Selector) > 0 { + errors = append(errors, fmt.Errorf("cannot set --all and --selector at the same time")) + } + if len(o.Resources) < 1 && cmdutil.IsFilenameSliceEmpty(o.Filenames, o.Kustomize) { + errors = append(errors, fmt.Errorf("one or more resources must be specified as or /")) + } + if len(o.ContainerImages) < 1 { + errors = append(errors, fmt.Errorf("at least one image update is required")) + } else if len(o.ContainerImages) > 1 && hasWildcardKey(o.ContainerImages) { + errors = append(errors, fmt.Errorf("all containers are already specified by *, but saw more than one container_name=container_image pairs")) + } + return utilerrors.NewAggregate(errors) +} + +// Run performs the execution of 'set image' sub command +func (o *SetImageOptions) Run() error { + allErrs := []error{} + + patches := CalculatePatches(o.Infos, scheme.DefaultJSONEncoder(), func(obj runtime.Object) ([]byte, error) { + _, err := o.UpdatePodSpecForObject(obj, func(spec *v1.PodSpec) error { + for name, image := range o.ContainerImages { + resolvedImageName, err := o.ResolveImage(image) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("error: unable to resolve image %q for container %q: %v", image, name, err)) + if name == "*" { + break + } + continue + } + + initContainerFound := setImage(spec.InitContainers, name, resolvedImageName) + containerFound := setImage(spec.Containers, name, resolvedImageName) + if !containerFound && !initContainerFound { + allErrs = append(allErrs, fmt.Errorf("error: unable to find container named %q", name)) + } + } + return nil + }) + if err != nil { + return nil, err + } + // record this change (for rollout history) + if err := o.Recorder.Record(obj); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + return runtime.Encode(scheme.DefaultJSONEncoder(), obj) + }) + + for _, patch := range patches { + info := patch.Info + if patch.Err != nil { + name := info.ObjectName() + allErrs = append(allErrs, fmt.Errorf("error: %s %v\n", name, patch.Err)) + continue + } + + // no changes + if string(patch.Patch) == "{}" || len(patch.Patch) == 0 { + continue + } + + if o.Local || o.DryRun { + if err := o.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + continue + } + + // patch the change + actual, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch.Patch, nil) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to patch image update to pod template: %v", err)) + continue + } + + if err := o.PrintObj(actual, o.Out); err != nil { + allErrs = append(allErrs, err) + } + } + return utilerrors.NewAggregate(allErrs) +} + +func setImage(containers []v1.Container, containerName string, image string) bool { + containerFound := false + // Find the container to update, and update its image + for i, c := range containers { + if c.Name == containerName || containerName == "*" { + containerFound = true + containers[i].Image = image + } + } + return containerFound +} + +// getResourcesAndImages retrieves resources and container name:images pair from given args +func getResourcesAndImages(args []string) (resources []string, containerImages map[string]string, err error) { + pairType := "image" + resources, imageArgs, err := cmdutil.GetResourcesAndPairs(args, pairType) + if err != nil { + return + } + containerImages, _, err = cmdutil.ParsePairs(imageArgs, pairType, false) + return +} + +func hasWildcardKey(containerImages map[string]string) bool { + _, ok := containerImages["*"] + return ok +} + +// ImageResolver is a func that receives an image name, and +// resolves it to an appropriate / compatible image name. +// Adds flexibility for future image resolving methods. +type ImageResolver func(in string) (string, error) + +// implements ImageResolver +func resolveImageFunc(in string) (string, error) { + return in, nil +} diff --git a/pkg/cmd/set/set_image_test.go b/pkg/cmd/set/set_image_test.go new file mode 100644 index 000000000..486f20613 --- /dev/null +++ b/pkg/cmd/set/set_image_test.go @@ -0,0 +1,776 @@ +/* +Copyright 2016 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 set + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + appsv1 "k8s.io/api/apps/v1" + appsv1beta1 "k8s.io/api/apps/v1beta1" + appsv1beta2 "k8s.io/api/apps/v1beta2" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestImageLocal(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: ""}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.ClientConfigVal = &restclient.Config{ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Version: ""}}} + + outputFormat := "name" + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdImage(tf, streams) + cmd.SetOutput(buf) + cmd.Flags().Set("output", outputFormat) + cmd.Flags().Set("local", "true") + + opts := SetImageOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + FilenameOptions: resource.FilenameOptions{ + Filenames: []string{"../../../../test/e2e/testing-manifests/statefulset/cassandra/controller.yaml"}}, + Local: true, + IOStreams: streams, + } + err := opts.Complete(tf, cmd, []string{"cassandra=thingy"}) + if err == nil { + err = opts.Validate() + } + if err == nil { + err = opts.Run() + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "replicationcontroller/cassandra") { + t.Errorf("did not set image: %s", buf.String()) + } +} + +func TestSetImageValidation(t *testing.T) { + printFlags := genericclioptions.NewPrintFlags("").WithTypeSetter(scheme.Scheme) + + testCases := []struct { + name string + imageOptions *SetImageOptions + expectErr string + }{ + { + name: "test resource < 1 and filenames empty", + imageOptions: &SetImageOptions{PrintFlags: printFlags}, + expectErr: "[one or more resources must be specified as or /, at least one image update is required]", + }, + { + name: "test containerImages < 1", + imageOptions: &SetImageOptions{ + PrintFlags: printFlags, + Resources: []string{"a", "b", "c"}, + + FilenameOptions: resource.FilenameOptions{ + Filenames: []string{"testFile"}, + }, + }, + expectErr: "at least one image update is required", + }, + { + name: "test containerImages > 1 and all containers are already specified by *", + imageOptions: &SetImageOptions{ + PrintFlags: printFlags, + Resources: []string{"a", "b", "c"}, + FilenameOptions: resource.FilenameOptions{ + Filenames: []string{"testFile"}, + }, + ContainerImages: map[string]string{ + "test": "test", + "*": "test", + }, + }, + expectErr: "all containers are already specified by *, but saw more than one container_name=container_image pairs", + }, + { + name: "success case", + imageOptions: &SetImageOptions{ + PrintFlags: printFlags, + Resources: []string{"a", "b", "c"}, + FilenameOptions: resource.FilenameOptions{ + Filenames: []string{"testFile"}, + }, + ContainerImages: map[string]string{ + "test": "test", + }, + }, + expectErr: "", + }, + } + for _, testCase := range testCases { + err := testCase.imageOptions.Validate() + if err != nil { + if err.Error() != testCase.expectErr { + t.Errorf("[%s]:expect err:%s got err:%s", testCase.name, testCase.expectErr, err.Error()) + } + } + if err == nil && (testCase.expectErr != "") { + t.Errorf("[%s]:expect err:%s got err:%v", testCase.name, testCase.expectErr, err) + } + } +} + +func TestSetMultiResourcesImageLocal(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: ""}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.ClientConfigVal = &restclient.Config{ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Version: ""}}} + + outputFormat := "name" + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdImage(tf, streams) + cmd.SetOutput(buf) + cmd.Flags().Set("output", outputFormat) + cmd.Flags().Set("local", "true") + + opts := SetImageOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + FilenameOptions: resource.FilenameOptions{ + Filenames: []string{"../../../../test/fixtures/pkg/kubectl/cmd/set/multi-resource-yaml.yaml"}}, + Local: true, + IOStreams: streams, + } + err := opts.Complete(tf, cmd, []string{"*=thingy"}) + if err == nil { + err = opts.Validate() + } + if err == nil { + err = opts.Run() + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedOut := "replicationcontroller/first-rc\nreplicationcontroller/second-rc\n" + if buf.String() != expectedOut { + t.Errorf("expected out:\n%s\nbut got:\n%s", expectedOut, buf.String()) + } +} + +func TestSetImageRemote(t *testing.T) { + inputs := []struct { + name string + object runtime.Object + groupVersion schema.GroupVersion + path string + args []string + }{ + { + name: "set image extensionsv1beta1 ReplicaSet", + object: &extensionsv1beta1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: extensionsv1beta1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", "*=thingy"}, + }, + { + name: "set image appsv1beta2 ReplicaSet", + object: &appsv1beta2.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", "*=thingy"}, + }, + { + name: "set image appsv1 ReplicaSet", + object: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", "*=thingy"}, + }, + { + name: "set image extensionsv1beta1 DaemonSet", + object: &extensionsv1beta1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: extensionsv1beta1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx", "*=thingy"}, + }, + { + name: "set image appsv1beta2 DaemonSet", + object: &appsv1beta2.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx", "*=thingy"}, + }, + { + name: "set image appsv1 DaemonSet", + object: &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx", "*=thingy"}, + }, + { + name: "set image extensionsv1beta1 Deployment", + object: &extensionsv1beta1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: extensionsv1beta1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", "*=thingy"}, + }, + { + name: "set image appsv1beta1 Deployment", + object: &appsv1beta1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", "*=thingy"}, + }, + { + name: "set image appsv1beta2 Deployment", + object: &appsv1beta2.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", "*=thingy"}, + }, + { + name: "set image appsv1 Deployment", + object: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", "*=thingy"}, + }, + { + name: "set image appsv1beta1 StatefulSet", + object: &appsv1beta1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx", "*=thingy"}, + }, + { + name: "set image appsv1beta2 StatefulSet", + object: &appsv1beta2.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx", "*=thingy"}, + }, + { + name: "set image appsv1 StatefulSet", + object: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx", "*=thingy"}, + }, + { + name: "set image batchv1 Job", + object: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: batchv1.SchemeGroupVersion, + path: "/namespaces/test/jobs/nginx", + args: []string{"job", "nginx", "*=thingy"}, + }, + { + name: "set image corev1.ReplicationController", + object: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: corev1.ReplicationControllerSpec{ + Template: &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: corev1.SchemeGroupVersion, + path: "/namespaces/test/replicationcontrollers/nginx", + args: []string{"replicationcontroller", "nginx", "*=thingy"}, + }, + } + for _, input := range inputs { + t.Run(input.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: input.groupVersion, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == input.path && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(input.object)}, nil + case p == input.path && m == http.MethodPatch: + stream, err := req.GetBody() + if err != nil { + return nil, err + } + bytes, err := ioutil.ReadAll(stream) + if err != nil { + return nil, err + } + assert.Contains(t, string(bytes), `"image":`+`"`+"thingy"+`"`, fmt.Sprintf("image not updated for %#v", input.object)) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(input.object)}, nil + default: + t.Errorf("%s: unexpected request: %s %#v\n%#v", "image", req.Method, req.URL, req) + return nil, fmt.Errorf("unexpected request") + } + }), + } + + outputFormat := "yaml" + + streams := genericclioptions.NewTestIOStreamsDiscard() + cmd := NewCmdImage(tf, streams) + cmd.Flags().Set("output", outputFormat) + opts := SetImageOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + + Local: false, + IOStreams: streams, + } + err := opts.Complete(tf, cmd, input.args) + assert.NoError(t, err) + err = opts.Run() + assert.NoError(t, err) + }) + } +} + +func TestSetImageRemoteWithSpecificContainers(t *testing.T) { + inputs := []struct { + name string + object runtime.Object + groupVersion schema.GroupVersion + path string + args []string + }{ + { + name: "set container image only", + object: &extensionsv1beta1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: extensionsv1beta1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + }, + }, + }, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", "nginx=thingy"}, + }, + { + name: "set initContainer image only", + object: &appsv1beta2.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "busybox", + Image: "busybox", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", "nginx=thingy"}, + }, + } + for _, input := range inputs { + t.Run(input.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: input.groupVersion, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == input.path && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(input.object)}, nil + case p == input.path && m == http.MethodPatch: + stream, err := req.GetBody() + if err != nil { + return nil, err + } + bytes, err := ioutil.ReadAll(stream) + if err != nil { + return nil, err + } + assert.Contains(t, string(bytes), `"image":"`+"thingy"+`","name":`+`"nginx"`, fmt.Sprintf("image not updated for %#v", input.object)) + assert.NotContains(t, string(bytes), `"image":"`+"thingy"+`","name":`+`"busybox"`, fmt.Sprintf("image updated for %#v", input.object)) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(input.object)}, nil + default: + t.Errorf("%s: unexpected request: %s %#v\n%#v", "image", req.Method, req.URL, req) + return nil, fmt.Errorf("unexpected request") + } + }), + } + + outputFormat := "yaml" + + streams := genericclioptions.NewTestIOStreamsDiscard() + cmd := NewCmdImage(tf, streams) + cmd.Flags().Set("output", outputFormat) + opts := SetImageOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + + Local: false, + IOStreams: streams, + } + err := opts.Complete(tf, cmd, input.args) + assert.NoError(t, err) + err = opts.Run() + assert.NoError(t, err) + }) + } +} diff --git a/pkg/cmd/set/set_resources.go b/pkg/cmd/set/set_resources.go new file mode 100644 index 000000000..87c5bc9fb --- /dev/null +++ b/pkg/cmd/set/set_resources.go @@ -0,0 +1,293 @@ +/* +Copyright 2016 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 set + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + generateversioned "k8s.io/kubectl/pkg/generate/versioned" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + resourcesLong = templates.LongDesc(` + Specify compute resource requirements (cpu, memory) for any resource that defines a pod template. If a pod is successfully scheduled, it is guaranteed the amount of resource requested, but may burst up to its specified limits. + + for each compute resource, if a limit is specified and a request is omitted, the request will default to the limit. + + Possible resources include (case insensitive): %s.`) + + resourcesExample = templates.Examples(` + # Set a deployments nginx container cpu limits to "200m" and memory to "512Mi" + kubectl set resources deployment nginx -c=nginx --limits=cpu=200m,memory=512Mi + + # Set the resource request and limits for all containers in nginx + kubectl set resources deployment nginx --limits=cpu=200m,memory=512Mi --requests=cpu=100m,memory=256Mi + + # Remove the resource requests for resources on containers in nginx + kubectl set resources deployment nginx --limits=cpu=0,memory=0 --requests=cpu=0,memory=0 + + # Print the result (in yaml format) of updating nginx container limits from a local, without hitting the server + kubectl set resources -f path/to/file.yaml --limits=cpu=200m,memory=512Mi --local -o yaml`) +) + +// SetResourcesOptions is the start of the data required to perform the operation. As new fields are added, add them here instead of +// referencing the cmd.Flags +type SetResourcesOptions struct { + resource.FilenameOptions + + PrintFlags *genericclioptions.PrintFlags + RecordFlags *genericclioptions.RecordFlags + + Infos []*resource.Info + Selector string + ContainerSelector string + Output string + All bool + Local bool + + DryRun bool + + PrintObj printers.ResourcePrinterFunc + Recorder genericclioptions.Recorder + + Limits string + Requests string + ResourceRequirements v1.ResourceRequirements + + UpdatePodSpecForObject polymorphichelpers.UpdatePodSpecForObjectFunc + Resources []string + + genericclioptions.IOStreams +} + +// NewResourcesOptions returns a ResourcesOptions indicating all containers in the selected +// pod templates are selected by default. +func NewResourcesOptions(streams genericclioptions.IOStreams) *SetResourcesOptions { + return &SetResourcesOptions{ + PrintFlags: genericclioptions.NewPrintFlags("resource requirements updated").WithTypeSetter(scheme.Scheme), + RecordFlags: genericclioptions.NewRecordFlags(), + + Recorder: genericclioptions.NoopRecorder{}, + + ContainerSelector: "*", + + IOStreams: streams, + } +} + +// NewCmdResources returns initialized Command instance for the 'set resources' sub command +func NewCmdResources(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewResourcesOptions(streams) + + cmd := &cobra.Command{ + Use: "resources (-f FILENAME | TYPE NAME) ([--limits=LIMITS & --requests=REQUESTS]", + DisableFlagsInUseLine: true, + Short: i18n.T("Update resource requests/limits on objects with pod templates"), + Long: fmt.Sprintf(resourcesLong, cmdutil.SuggestAPIResources("kubectl")), + Example: resourcesExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run()) + }, + } + + o.PrintFlags.AddFlags(cmd) + o.RecordFlags.AddFlags(cmd) + + //usage := "Filename, directory, or URL to a file identifying the resource to get from the server" + //kubectl.AddJsonFilenameFlag(cmd, &options.Filenames, usage) + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, usage) + cmd.Flags().BoolVar(&o.All, "all", o.All, "Select all resources, including uninitialized ones, in the namespace of the specified resource types") + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on, not including uninitialized ones,supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().StringVarP(&o.ContainerSelector, "containers", "c", o.ContainerSelector, "The names of containers in the selected pod templates to change, all containers are selected by default - may use wildcards") + cmd.Flags().BoolVar(&o.Local, "local", o.Local, "If true, set resources will NOT contact api-server but run locally.") + cmdutil.AddDryRunFlag(cmd) + cmdutil.AddIncludeUninitializedFlag(cmd) + cmd.Flags().StringVar(&o.Limits, "limits", o.Limits, "The resource requirement requests for this container. For example, 'cpu=100m,memory=256Mi'. Note that server side components may assign requests depending on the server configuration, such as limit ranges.") + cmd.Flags().StringVar(&o.Requests, "requests", o.Requests, "The resource requirement requests for this container. For example, 'cpu=100m,memory=256Mi'. Note that server side components may assign requests depending on the server configuration, such as limit ranges.") + return cmd +} + +// Complete completes all required options +func (o *SetResourcesOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.UpdatePodSpecForObject = polymorphichelpers.UpdatePodSpecForObjectFn + o.Output = cmdutil.GetFlagString(cmd, "output") + o.DryRun = cmdutil.GetDryRunFlag(cmd) + + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = printer.PrintObj + + cmdNamespace, enforceNamespace, err := f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + builder := f.NewBuilder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + LocalParam(o.Local). + ContinueOnError(). + NamespaceParam(cmdNamespace).DefaultNamespace(). + FilenameParam(enforceNamespace, &o.FilenameOptions). + Flatten() + + if !o.Local { + builder.LabelSelectorParam(o.Selector). + ResourceTypeOrNameArgs(o.All, args...). + Latest() + } else { + // if a --local flag was provided, and a resource was specified in the form + // /, fail immediately as --local cannot query the api server + // for the specified resource. + // TODO: this should be in the builder - if someone specifies tuples, fail when + // local is true + if len(args) > 0 { + return resource.LocalResourceError + } + } + + o.Infos, err = builder.Do().Infos() + if err != nil { + return err + } + return nil +} + +// Validate makes sure that provided values in ResourcesOptions are valid +func (o *SetResourcesOptions) Validate() error { + var err error + if o.All && len(o.Selector) > 0 { + return fmt.Errorf("cannot set --all and --selector at the same time") + } + if len(o.Limits) == 0 && len(o.Requests) == 0 { + return fmt.Errorf("you must specify an update to requests or limits (in the form of --requests/--limits)") + } + + o.ResourceRequirements, err = generateversioned.HandleResourceRequirementsV1(map[string]string{"limits": o.Limits, "requests": o.Requests}) + if err != nil { + return err + } + + return nil +} + +// Run performs the execution of 'set resources' sub command +func (o *SetResourcesOptions) Run() error { + allErrs := []error{} + patches := CalculatePatches(o.Infos, scheme.DefaultJSONEncoder(), func(obj runtime.Object) ([]byte, error) { + transformed := false + _, err := o.UpdatePodSpecForObject(obj, func(spec *v1.PodSpec) error { + containers, _ := selectContainers(spec.Containers, o.ContainerSelector) + if len(containers) != 0 { + for i := range containers { + if len(o.Limits) != 0 && len(containers[i].Resources.Limits) == 0 { + containers[i].Resources.Limits = make(v1.ResourceList) + } + for key, value := range o.ResourceRequirements.Limits { + containers[i].Resources.Limits[key] = value + } + + if len(o.Requests) != 0 && len(containers[i].Resources.Requests) == 0 { + containers[i].Resources.Requests = make(v1.ResourceList) + } + for key, value := range o.ResourceRequirements.Requests { + containers[i].Resources.Requests[key] = value + } + transformed = true + } + } else { + allErrs = append(allErrs, fmt.Errorf("error: unable to find container named %s", o.ContainerSelector)) + } + return nil + }) + if err != nil { + return nil, err + } + if !transformed { + return nil, nil + } + // record this change (for rollout history) + if err := o.Recorder.Record(obj); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + return runtime.Encode(scheme.DefaultJSONEncoder(), obj) + }) + + for _, patch := range patches { + info := patch.Info + name := info.ObjectName() + if patch.Err != nil { + allErrs = append(allErrs, fmt.Errorf("error: %s %v\n", name, patch.Err)) + continue + } + + //no changes + if string(patch.Patch) == "{}" || len(patch.Patch) == 0 { + allErrs = append(allErrs, fmt.Errorf("info: %s was not changed\n", name)) + continue + } + + if o.Local || o.DryRun { + if err := o.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + continue + } + + actual, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch.Patch, nil) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to patch limit update to pod template %v", err)) + continue + } + + if err := o.PrintObj(actual, o.Out); err != nil { + allErrs = append(allErrs, err) + } + } + return utilerrors.NewAggregate(allErrs) +} diff --git a/pkg/cmd/set/set_resources_test.go b/pkg/cmd/set/set_resources_test.go new file mode 100644 index 000000000..1302a0a6d --- /dev/null +++ b/pkg/cmd/set/set_resources_test.go @@ -0,0 +1,518 @@ +/* +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 set + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + appsv1beta1 "k8s.io/api/apps/v1beta1" + appsv1beta2 "k8s.io/api/apps/v1beta2" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestResourcesLocal(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: ""}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.ClientConfigVal = &restclient.Config{ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Version: ""}}} + + outputFormat := "name" + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdResources(tf, streams) + cmd.SetOutput(buf) + cmd.Flags().Set("output", outputFormat) + cmd.Flags().Set("local", "true") + + opts := SetResourcesOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + FilenameOptions: resource.FilenameOptions{ + Filenames: []string{"../../../../test/e2e/testing-manifests/statefulset/cassandra/controller.yaml"}}, + Local: true, + Limits: "cpu=200m,memory=512Mi", + Requests: "cpu=200m,memory=512Mi", + ContainerSelector: "*", + IOStreams: streams, + } + + err := opts.Complete(tf, cmd, []string{}) + if err == nil { + err = opts.Validate() + } + if err == nil { + err = opts.Run() + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "replicationcontroller/cassandra") { + t.Errorf("did not set resources: %s", buf.String()) + } +} + +func TestSetMultiResourcesLimitsLocal(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: ""}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.ClientConfigVal = &restclient.Config{ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Version: ""}}} + + outputFormat := "name" + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdResources(tf, streams) + cmd.SetOutput(buf) + cmd.Flags().Set("output", outputFormat) + cmd.Flags().Set("local", "true") + + opts := SetResourcesOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + FilenameOptions: resource.FilenameOptions{ + Filenames: []string{"../../../../test/fixtures/pkg/kubectl/cmd/set/multi-resource-yaml.yaml"}}, + Local: true, + Limits: "cpu=200m,memory=512Mi", + Requests: "cpu=200m,memory=512Mi", + ContainerSelector: "*", + IOStreams: streams, + } + + err := opts.Complete(tf, cmd, []string{}) + if err == nil { + err = opts.Validate() + } + if err == nil { + err = opts.Run() + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedOut := "replicationcontroller/first-rc\nreplicationcontroller/second-rc\n" + if buf.String() != expectedOut { + t.Errorf("expected out:\n%s\nbut got:\n%s", expectedOut, buf.String()) + } +} + +func TestSetResourcesRemote(t *testing.T) { + inputs := []struct { + name string + object runtime.Object + groupVersion schema.GroupVersion + path string + args []string + }{ + { + name: "set image extensionsv1beta1 ReplicaSet", + object: &extensionsv1beta1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: extensionsv1beta1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx"}, + }, + { + name: "set image appsv1beta2 ReplicaSet", + object: &appsv1beta2.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx"}, + }, + { + name: "set image appsv1 ReplicaSet", + object: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx"}, + }, + { + name: "set image extensionsv1beta1 DaemonSet", + object: &extensionsv1beta1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: extensionsv1beta1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx"}, + }, + { + name: "set image appsv1beta2 DaemonSet", + object: &appsv1beta2.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx"}, + }, + { + name: "set image appsv1 DaemonSet", + object: &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx"}, + }, + { + name: "set image extensionsv1beta1 Deployment", + object: &extensionsv1beta1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: extensionsv1beta1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx"}, + }, + { + name: "set image appsv1beta1 Deployment", + object: &appsv1beta1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx"}, + }, + { + name: "set image appsv1beta2 Deployment", + object: &appsv1beta2.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx"}, + }, + { + name: "set image appsv1 Deployment", + object: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx"}, + }, + { + name: "set image appsv1beta1 StatefulSet", + object: &appsv1beta1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx"}, + }, + { + name: "set image appsv1beta2 StatefulSet", + object: &appsv1beta2.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx"}, + }, + { + name: "set image appsv1 StatefulSet", + object: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx"}, + }, + { + name: "set image batchv1 Job", + object: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: batchv1.SchemeGroupVersion, + path: "/namespaces/test/jobs/nginx", + args: []string{"job", "nginx"}, + }, + { + name: "set image corev1.ReplicationController", + object: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: corev1.ReplicationControllerSpec{ + Template: &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: corev1.SchemeGroupVersion, + path: "/namespaces/test/replicationcontrollers/nginx", + args: []string{"replicationcontroller", "nginx"}, + }, + } + + for i, input := range inputs { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: input.groupVersion, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == input.path && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(input.object)}, nil + case p == input.path && m == http.MethodPatch: + stream, err := req.GetBody() + if err != nil { + return nil, err + } + bytes, err := ioutil.ReadAll(stream) + if err != nil { + return nil, err + } + assert.Contains(t, string(bytes), "200m", fmt.Sprintf("resources not updated for %#v", input.object)) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(input.object)}, nil + default: + t.Errorf("%s: unexpected request: %s %#v\n%#v", "resources", req.Method, req.URL, req) + return nil, fmt.Errorf("unexpected request") + } + }), + } + + outputFormat := "yaml" + + streams := genericclioptions.NewTestIOStreamsDiscard() + cmd := NewCmdResources(tf, streams) + cmd.Flags().Set("output", outputFormat) + opts := SetResourcesOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + + Limits: "cpu=200m,memory=512Mi", + ContainerSelector: "*", + IOStreams: streams, + } + err := opts.Complete(tf, cmd, input.args) + if err == nil { + err = opts.Validate() + } + if err == nil { + err = opts.Run() + } + assert.NoError(t, err) + }) + } +} diff --git a/pkg/cmd/set/set_selector.go b/pkg/cmd/set/set_selector.go new file mode 100644 index 000000000..a7cb0259f --- /dev/null +++ b/pkg/cmd/set/set_selector.go @@ -0,0 +1,225 @@ +/* +Copyright 2016 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 set + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// SetSelectorOptions is the start of the data required to perform the operation. As new fields are added, add them here instead of +// referencing the cmd.Flags() +type SetSelectorOptions struct { + // Bound + ResourceBuilderFlags *genericclioptions.ResourceBuilderFlags + PrintFlags *genericclioptions.PrintFlags + RecordFlags *genericclioptions.RecordFlags + dryrun bool + + // set by args + resources []string + selector *metav1.LabelSelector + + // computed + WriteToServer bool + PrintObj printers.ResourcePrinterFunc + Recorder genericclioptions.Recorder + ResourceFinder genericclioptions.ResourceFinder + + // set at initialization + genericclioptions.IOStreams +} + +var ( + selectorLong = templates.LongDesc(` + Set the selector on a resource. Note that the new selector will overwrite the old selector if the resource had one prior to the invocation + of 'set selector'. + + A selector must begin with a letter or number, and may contain letters, numbers, hyphens, dots, and underscores, up to %[1]d characters. + If --resource-version is specified, then updates will use this resource version, otherwise the existing resource-version will be used. + Note: currently selectors can only be set on Service objects.`) + selectorExample = templates.Examples(` + # set the labels and selector before creating a deployment/service pair. + kubectl create service clusterip my-svc --clusterip="None" -o yaml --dry-run | kubectl set selector --local -f - 'environment=qa' -o yaml | kubectl create -f - + kubectl create deployment my-dep -o yaml --dry-run | kubectl label --local -f - environment=qa -o yaml | kubectl create -f -`) +) + +// NewSelectorOptions returns an initialized SelectorOptions instance +func NewSelectorOptions(streams genericclioptions.IOStreams) *SetSelectorOptions { + return &SetSelectorOptions{ + ResourceBuilderFlags: genericclioptions.NewResourceBuilderFlags(). + WithScheme(scheme.Scheme). + WithAll(false). + WithLocal(false). + WithLatest(), + PrintFlags: genericclioptions.NewPrintFlags("selector updated").WithTypeSetter(scheme.Scheme), + RecordFlags: genericclioptions.NewRecordFlags(), + + Recorder: genericclioptions.NoopRecorder{}, + + IOStreams: streams, + } +} + +// NewCmdSelector is the "set selector" command. +func NewCmdSelector(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewSelectorOptions(streams) + + cmd := &cobra.Command{ + Use: "selector (-f FILENAME | TYPE NAME) EXPRESSIONS [--resource-version=version]", + DisableFlagsInUseLine: true, + Short: i18n.T("Set the selector on a resource"), + Long: fmt.Sprintf(selectorLong, validation.LabelValueMaxLength), + Example: selectorExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunSelector()) + }, + } + + o.ResourceBuilderFlags.AddFlags(cmd.Flags()) + o.PrintFlags.AddFlags(cmd) + o.RecordFlags.AddFlags(cmd) + + cmd.Flags().String("resource-version", "", "If non-empty, the selectors update will only succeed if this is the current resource-version for the object. Only valid when specifying a single resource.") + cmdutil.AddDryRunFlag(cmd) + + return cmd +} + +// Complete assigns the SelectorOptions from args. +func (o *SetSelectorOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.dryrun = cmdutil.GetDryRunFlag(cmd) + + o.resources, o.selector, err = getResourcesAndSelector(args) + if err != nil { + return err + } + + o.ResourceFinder = o.ResourceBuilderFlags.ToBuilder(f, o.resources) + o.WriteToServer = !(*o.ResourceBuilderFlags.Local || o.dryrun) + + if o.dryrun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = printer.PrintObj + + return err +} + +// Validate basic inputs +func (o *SetSelectorOptions) Validate() error { + if o.selector == nil { + return fmt.Errorf("one selector is required") + } + return nil +} + +// RunSelector executes the command. +func (o *SetSelectorOptions) RunSelector() error { + r := o.ResourceFinder.Do() + + return r.Visit(func(info *resource.Info, err error) error { + patch := &Patch{Info: info} + CalculatePatch(patch, scheme.DefaultJSONEncoder(), func(obj runtime.Object) ([]byte, error) { + selectErr := updateSelectorForObject(info.Object, *o.selector) + if selectErr != nil { + return nil, selectErr + } + + // record this change (for rollout history) + if err := o.Recorder.Record(patch.Info.Object); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + return runtime.Encode(scheme.DefaultJSONEncoder(), info.Object) + }) + + if patch.Err != nil { + return patch.Err + } + if !o.WriteToServer { + return o.PrintObj(info.Object, o.Out) + } + + actual, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch.Patch, nil) + if err != nil { + return err + } + + return o.PrintObj(actual, o.Out) + }) +} + +func updateSelectorForObject(obj runtime.Object, selector metav1.LabelSelector) error { + copyOldSelector := func() (map[string]string, error) { + if len(selector.MatchExpressions) > 0 { + return nil, fmt.Errorf("match expression %v not supported on this object", selector.MatchExpressions) + } + dst := make(map[string]string) + for label, value := range selector.MatchLabels { + dst[label] = value + } + return dst, nil + } + var err error + switch t := obj.(type) { + case *v1.Service: + t.Spec.Selector, err = copyOldSelector() + default: + err = fmt.Errorf("setting a selector is only supported for Services") + } + return err +} + +// getResourcesAndSelector retrieves resources and the selector expression from the given args (assuming selectors the last arg) +func getResourcesAndSelector(args []string) (resources []string, selector *metav1.LabelSelector, err error) { + if len(args) == 0 { + return []string{}, nil, nil + } + resources = args[:len(args)-1] + selector, err = metav1.ParseToLabelSelector(args[len(args)-1]) + return resources, selector, err +} diff --git a/pkg/cmd/set/set_selector_test.go b/pkg/cmd/set/set_selector_test.go new file mode 100644 index 000000000..e36c5fa94 --- /dev/null +++ b/pkg/cmd/set/set_selector_test.go @@ -0,0 +1,342 @@ +/* +Copyright 2016 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 set + +import ( + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + batchv1 "k8s.io/api/batch/v1" + "k8s.io/api/core/v1" + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" +) + +func TestUpdateSelectorForObjectTypes(t *testing.T) { + before := metav1.LabelSelector{MatchLabels: map[string]string{"fee": "true"}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "foo", + Operator: metav1.LabelSelectorOpIn, + Values: []string{"on", "yes"}, + }, + }} + + rc := v1.ReplicationController{} + ser := v1.Service{} + dep := extensionsv1beta1.Deployment{Spec: extensionsv1beta1.DeploymentSpec{Selector: &before}} + ds := extensionsv1beta1.DaemonSet{Spec: extensionsv1beta1.DaemonSetSpec{Selector: &before}} + rs := extensionsv1beta1.ReplicaSet{Spec: extensionsv1beta1.ReplicaSetSpec{Selector: &before}} + job := batchv1.Job{Spec: batchv1.JobSpec{Selector: &before}} + pvc := v1.PersistentVolumeClaim{Spec: v1.PersistentVolumeClaimSpec{Selector: &before}} + sa := v1.ServiceAccount{} + type args struct { + obj runtime.Object + selector metav1.LabelSelector + } + tests := []struct { + name string + args args + wantErr bool + }{ + {name: "rc", + args: args{ + obj: &rc, + selector: metav1.LabelSelector{}, + }, + wantErr: true, + }, + {name: "ser", + args: args{ + obj: &ser, + selector: metav1.LabelSelector{}, + }, + wantErr: false, + }, + {name: "dep", + args: args{ + obj: &dep, + selector: metav1.LabelSelector{}, + }, + wantErr: true, + }, + {name: "ds", + args: args{ + obj: &ds, + selector: metav1.LabelSelector{}, + }, + wantErr: true, + }, + {name: "rs", + args: args{ + obj: &rs, + selector: metav1.LabelSelector{}, + }, + wantErr: true, + }, + {name: "job", + args: args{ + obj: &job, + selector: metav1.LabelSelector{}, + }, + wantErr: true, + }, + {name: "pvc - no updates", + args: args{ + obj: &pvc, + selector: metav1.LabelSelector{}, + }, + wantErr: true, + }, + {name: "sa - no selector", + args: args{ + obj: &sa, + selector: metav1.LabelSelector{}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + if err := updateSelectorForObject(tt.args.obj, tt.args.selector); (err != nil) != tt.wantErr { + t.Errorf("%q. updateSelectorForObject() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + } +} + +func TestUpdateNewSelectorValuesForObject(t *testing.T) { + ser := v1.Service{} + type args struct { + obj runtime.Object + selector metav1.LabelSelector + } + tests := []struct { + name string + args args + wantErr bool + }{ + {name: "empty", + args: args{ + obj: &ser, + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{}, + MatchExpressions: []metav1.LabelSelectorRequirement{}, + }, + }, + wantErr: false, + }, + {name: "label-only", + args: args{ + obj: &ser, + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"b": "u"}, + MatchExpressions: []metav1.LabelSelectorRequirement{}, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + if err := updateSelectorForObject(tt.args.obj, tt.args.selector); (err != nil) != tt.wantErr { + t.Errorf("%q. updateSelectorForObject() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + + assert.EqualValues(t, tt.args.selector.MatchLabels, ser.Spec.Selector, tt.name) + + } +} + +func TestUpdateOldSelectorValuesForObject(t *testing.T) { + ser := v1.Service{Spec: v1.ServiceSpec{Selector: map[string]string{"fee": "true"}}} + type args struct { + obj runtime.Object + selector metav1.LabelSelector + } + tests := []struct { + name string + args args + wantErr bool + }{ + {name: "empty", + args: args{ + obj: &ser, + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{}, + MatchExpressions: []metav1.LabelSelectorRequirement{}, + }, + }, + wantErr: false, + }, + {name: "label-only", + args: args{ + obj: &ser, + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"fee": "false", "x": "y"}, + MatchExpressions: []metav1.LabelSelectorRequirement{}, + }, + }, + wantErr: false, + }, + {name: "expr-only - err", + args: args{ + obj: &ser, + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "a", + Operator: "In", + Values: []string{"x", "y"}, + }, + }, + }, + }, + wantErr: true, + }, + {name: "both - err", + args: args{ + obj: &ser, + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"b": "u"}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "a", + Operator: "In", + Values: []string{"x", "y"}, + }, + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + err := updateSelectorForObject(tt.args.obj, tt.args.selector) + if (err != nil) != tt.wantErr { + t.Errorf("%q. updateSelectorForObject() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } else if !tt.wantErr { + assert.EqualValues(t, tt.args.selector.MatchLabels, ser.Spec.Selector, tt.name) + } + } +} + +func TestGetResourcesAndSelector(t *testing.T) { + type args struct { + args []string + } + tests := []struct { + name string + args args + wantResources []string + wantSelector *metav1.LabelSelector + wantErr bool + }{ + { + name: "basic match", + args: args{args: []string{"rc/foo", "healthy=true"}}, + wantResources: []string{"rc/foo"}, + wantErr: false, + wantSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"healthy": "true"}, + MatchExpressions: []metav1.LabelSelectorRequirement{}, + }, + }, + { + name: "basic expression", + args: args{args: []string{"rc/foo", "buildType notin (debug, test)"}}, + wantResources: []string{"rc/foo"}, + wantErr: false, + wantSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "buildType", + Operator: "NotIn", + Values: []string{"debug", "test"}, + }, + }, + }, + }, + { + name: "selector error", + args: args{args: []string{"rc/foo", "buildType notthis (debug, test)"}}, + wantResources: []string{"rc/foo"}, + wantErr: true, + wantSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{}, + MatchExpressions: []metav1.LabelSelectorRequirement{}, + }, + }, + { + name: "no resource and selector", + args: args{args: []string{}}, + wantResources: []string{}, + wantErr: false, + wantSelector: nil, + }, + } + for _, tt := range tests { + gotResources, gotSelector, err := getResourcesAndSelector(tt.args.args) + if err != nil { + if !tt.wantErr { + t.Errorf("%q. getResourcesAndSelector() error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + continue + } + if !reflect.DeepEqual(gotResources, tt.wantResources) { + t.Errorf("%q. getResourcesAndSelector() gotResources = %v, want %v", tt.name, gotResources, tt.wantResources) + } + if !reflect.DeepEqual(gotSelector, tt.wantSelector) { + t.Errorf("%q. getResourcesAndSelector() gotSelector = %v, want %v", tt.name, gotSelector, tt.wantSelector) + } + } +} + +func TestSelectorTest(t *testing.T) { + info := &resource.Info{ + Object: &v1.Service{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"}, + ObjectMeta: metav1.ObjectMeta{Namespace: "some-ns", Name: "cassandra"}, + }, + } + + labelToSet, err := metav1.ParseToLabelSelector("environment=qa") + if err != nil { + t.Fatal(err) + } + + iostreams, _, buf, _ := genericclioptions.NewTestIOStreams() + o := &SetSelectorOptions{ + selector: labelToSet, + ResourceFinder: genericclioptions.NewSimpleFakeResourceFinder(info), + Recorder: genericclioptions.NoopRecorder{}, + PrintObj: (&printers.NamePrinter{}).PrintObj, + IOStreams: iostreams, + } + + if err := o.RunSelector(); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "service/cassandra") { + t.Errorf("did not set selector: %s", buf.String()) + } +} diff --git a/pkg/cmd/set/set_serviceaccount.go b/pkg/cmd/set/set_serviceaccount.go new file mode 100644 index 000000000..0c3b6a5e7 --- /dev/null +++ b/pkg/cmd/set/set_serviceaccount.go @@ -0,0 +1,216 @@ +/* +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 set + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/polymorphichelpers" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + serviceaccountResources = ` + replicationcontroller (rc), deployment (deploy), daemonset (ds), job, replicaset (rs), statefulset` + + serviceaccountLong = templates.LongDesc(i18n.T(` + Update ServiceAccount of pod template resources. + + Possible resources (case insensitive) can be: + ` + serviceaccountResources)) + + serviceaccountExample = templates.Examples(i18n.T(` + # Set Deployment nginx-deployment's ServiceAccount to serviceaccount1 + kubectl set serviceaccount deployment nginx-deployment serviceaccount1 + + # Print the result (in yaml format) of updated nginx deployment with serviceaccount from local file, without hitting apiserver + kubectl set sa -f nginx-deployment.yaml serviceaccount1 --local --dry-run -o yaml + `)) +) + +// SetServiceAccountOptions encapsulates the data required to perform the operation. +type SetServiceAccountOptions struct { + PrintFlags *genericclioptions.PrintFlags + RecordFlags *genericclioptions.RecordFlags + + fileNameOptions resource.FilenameOptions + dryRun bool + shortOutput bool + all bool + output string + local bool + updatePodSpecForObject polymorphichelpers.UpdatePodSpecForObjectFunc + infos []*resource.Info + serviceAccountName string + + PrintObj printers.ResourcePrinterFunc + Recorder genericclioptions.Recorder + + genericclioptions.IOStreams +} + +// NewSetServiceAccountOptions returns an initialized SetServiceAccountOptions instance +func NewSetServiceAccountOptions(streams genericclioptions.IOStreams) *SetServiceAccountOptions { + return &SetServiceAccountOptions{ + PrintFlags: genericclioptions.NewPrintFlags("serviceaccount updated").WithTypeSetter(scheme.Scheme), + RecordFlags: genericclioptions.NewRecordFlags(), + + Recorder: genericclioptions.NoopRecorder{}, + + IOStreams: streams, + } +} + +// NewCmdServiceAccount returns the "set serviceaccount" command. +func NewCmdServiceAccount(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewSetServiceAccountOptions(streams) + + cmd := &cobra.Command{ + Use: "serviceaccount (-f FILENAME | TYPE NAME) SERVICE_ACCOUNT", + DisableFlagsInUseLine: true, + Aliases: []string{"sa"}, + Short: i18n.T("Update ServiceAccount of a resource"), + Long: serviceaccountLong, + Example: serviceaccountExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Run()) + }, + } + + o.PrintFlags.AddFlags(cmd) + o.RecordFlags.AddFlags(cmd) + + usage := "identifying the resource to get from a server." + cmdutil.AddFilenameOptionFlags(cmd, &o.fileNameOptions, usage) + cmd.Flags().BoolVar(&o.all, "all", o.all, "Select all resources, including uninitialized ones, in the namespace of the specified resource types") + cmd.Flags().BoolVar(&o.local, "local", o.local, "If true, set serviceaccount will NOT contact api-server but run locally.") + cmdutil.AddDryRunFlag(cmd) + cmdutil.AddIncludeUninitializedFlag(cmd) + return cmd +} + +// Complete configures serviceAccountConfig from command line args. +func (o *SetServiceAccountOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + + o.RecordFlags.Complete(cmd) + o.Recorder, err = o.RecordFlags.ToRecorder() + if err != nil { + return err + } + + o.shortOutput = cmdutil.GetFlagString(cmd, "output") == "name" + o.dryRun = cmdutil.GetDryRunFlag(cmd) + o.output = cmdutil.GetFlagString(cmd, "output") + o.updatePodSpecForObject = polymorphichelpers.UpdatePodSpecForObjectFn + + if o.dryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = printer.PrintObj + + cmdNamespace, enforceNamespace, err := f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + if len(args) == 0 { + return errors.New("serviceaccount is required") + } + o.serviceAccountName = args[len(args)-1] + resources := args[:len(args)-1] + builder := f.NewBuilder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + LocalParam(o.local). + ContinueOnError(). + NamespaceParam(cmdNamespace).DefaultNamespace(). + FilenameParam(enforceNamespace, &o.fileNameOptions). + Flatten() + if !o.local { + builder.ResourceTypeOrNameArgs(o.all, resources...). + Latest() + } + o.infos, err = builder.Do().Infos() + if err != nil { + return err + } + return nil +} + +// Run creates and applies the patch either locally or calling apiserver. +func (o *SetServiceAccountOptions) Run() error { + patchErrs := []error{} + patchFn := func(obj runtime.Object) ([]byte, error) { + _, err := o.updatePodSpecForObject(obj, func(podSpec *v1.PodSpec) error { + podSpec.ServiceAccountName = o.serviceAccountName + return nil + }) + if err != nil { + return nil, err + } + // record this change (for rollout history) + if err := o.Recorder.Record(obj); err != nil { + klog.V(4).Infof("error recording current command: %v", err) + } + + return runtime.Encode(scheme.DefaultJSONEncoder(), obj) + } + + patches := CalculatePatches(o.infos, scheme.DefaultJSONEncoder(), patchFn) + for _, patch := range patches { + info := patch.Info + name := info.ObjectName() + if patch.Err != nil { + patchErrs = append(patchErrs, fmt.Errorf("error: %s %v\n", name, patch.Err)) + continue + } + if o.local || o.dryRun { + if err := o.PrintObj(info.Object, o.Out); err != nil { + patchErrs = append(patchErrs, err) + } + continue + } + actual, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch.Patch, nil) + if err != nil { + patchErrs = append(patchErrs, fmt.Errorf("failed to patch ServiceAccountName %v", err)) + continue + } + + if err := o.PrintObj(actual, o.Out); err != nil { + patchErrs = append(patchErrs, err) + } + } + return utilerrors.NewAggregate(patchErrs) +} diff --git a/pkg/cmd/set/set_serviceaccount_test.go b/pkg/cmd/set/set_serviceaccount_test.go new file mode 100644 index 000000000..1043999a8 --- /dev/null +++ b/pkg/cmd/set/set_serviceaccount_test.go @@ -0,0 +1,407 @@ +/* +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 set + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + appsv1beta1 "k8s.io/api/apps/v1beta1" + appsv1beta2 "k8s.io/api/apps/v1beta2" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +const ( + serviceAccount = "serviceaccount1" + serviceAccountMissingErrString = "serviceaccount is required" + resourceMissingErrString = `You must provide one or more resources by argument or filename. +Example resource specifications include: + '-f rsrc.yaml' + '--filename=rsrc.json' + ' ' + ''` +) + +func TestSetServiceAccountLocal(t *testing.T) { + inputs := []struct { + yaml string + apiGroup string + }{ + {yaml: "../../../../test/fixtures/doc-yaml/user-guide/replication.yaml", apiGroup: ""}, + {yaml: "../../../../test/fixtures/doc-yaml/admin/daemon.yaml", apiGroup: "extensions"}, + {yaml: "../../../../test/fixtures/doc-yaml/user-guide/replicaset/redis-slave.yaml", apiGroup: "extensions"}, + {yaml: "../../../../test/fixtures/doc-yaml/user-guide/job.yaml", apiGroup: "batch"}, + {yaml: "../../../../test/fixtures/doc-yaml/user-guide/deployment.yaml", apiGroup: "extensions"}, + } + + for i, input := range inputs { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + + outputFormat := "yaml" + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdServiceAccount(tf, streams) + cmd.Flags().Set("output", outputFormat) + cmd.Flags().Set("local", "true") + saConfig := SetServiceAccountOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + fileNameOptions: resource.FilenameOptions{ + Filenames: []string{input.yaml}}, + local: true, + IOStreams: streams, + } + err := saConfig.Complete(tf, cmd, []string{serviceAccount}) + assert.NoError(t, err) + err = saConfig.Run() + assert.NoError(t, err) + assert.Contains(t, buf.String(), "serviceAccountName: "+serviceAccount, fmt.Sprintf("serviceaccount not updated for %s", input.yaml)) + }) + } +} + +func TestSetServiceAccountMultiLocal(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: ""}, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + tf.ClientConfigVal = &restclient.Config{ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Version: ""}}} + + outputFormat := "name" + + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdServiceAccount(tf, streams) + cmd.Flags().Set("output", outputFormat) + cmd.Flags().Set("local", "true") + opts := SetServiceAccountOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + fileNameOptions: resource.FilenameOptions{ + Filenames: []string{"../../../../test/fixtures/pkg/kubectl/cmd/set/multi-resource-yaml.yaml"}}, + local: true, + IOStreams: streams, + } + + err := opts.Complete(tf, cmd, []string{serviceAccount}) + if err == nil { + err = opts.Run() + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedOut := "replicationcontroller/first-rc\nreplicationcontroller/second-rc\n" + if buf.String() != expectedOut { + t.Errorf("expected out:\n%s\nbut got:\n%s", expectedOut, buf.String()) + } +} + +func TestSetServiceAccountRemote(t *testing.T) { + inputs := []struct { + object runtime.Object + groupVersion schema.GroupVersion + path string + args []string + }{ + { + object: &extensionsv1beta1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", serviceAccount}, + }, + { + object: &appsv1beta2.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1beta2.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", serviceAccount}, + }, + { + object: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/replicasets/nginx", + args: []string{"replicaset", "nginx", serviceAccount}, + }, + { + object: &extensionsv1beta1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx", serviceAccount}, + }, + { + object: &appsv1beta2.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx", serviceAccount}, + }, + { + object: &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/daemonsets/nginx", + args: []string{"daemonset", "nginx", serviceAccount}, + }, + { + object: &extensionsv1beta1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: extensionsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", serviceAccount}, + }, + { + object: &appsv1beta1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: appsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", serviceAccount}, + }, + { + object: &appsv1beta2.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", serviceAccount}, + }, + { + object: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/deployments/nginx", + args: []string{"deployment", "nginx", serviceAccount}, + }, + { + object: &appsv1beta1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: appsv1beta1.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx", serviceAccount}, + }, + { + object: &appsv1beta2.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: appsv1beta2.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx", serviceAccount}, + }, + { + object: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + }, + groupVersion: appsv1.SchemeGroupVersion, + path: "/namespaces/test/statefulsets/nginx", + args: []string{"statefulset", "nginx", serviceAccount}, + }, + { + object: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: batchv1.SchemeGroupVersion, + path: "/namespaces/test/jobs/nginx", + args: []string{"job", "nginx", serviceAccount}, + }, + { + object: &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{Name: "nginx"}, + }, + groupVersion: corev1.SchemeGroupVersion, + path: "/namespaces/test/replicationcontrollers/nginx", + args: []string{"replicationcontroller", "nginx", serviceAccount}, + }, + } + for i, input := range inputs { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: input.groupVersion, + NegotiatedSerializer: scheme.Codecs.WithoutConversion(), + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == input.path && m == http.MethodGet: + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(input.object)}, nil + case p == input.path && m == http.MethodPatch: + stream, err := req.GetBody() + if err != nil { + return nil, err + } + bytes, err := ioutil.ReadAll(stream) + if err != nil { + return nil, err + } + assert.Contains(t, string(bytes), `"serviceAccountName":`+`"`+serviceAccount+`"`, fmt.Sprintf("serviceaccount not updated for %#v", input.object)) + return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: objBody(input.object)}, nil + default: + t.Errorf("%s: unexpected request: %s %#v\n%#v", "serviceaccount", req.Method, req.URL, req) + return nil, fmt.Errorf("unexpected request") + } + }), + } + + outputFormat := "yaml" + + streams := genericclioptions.NewTestIOStreamsDiscard() + cmd := NewCmdServiceAccount(tf, streams) + cmd.Flags().Set("output", outputFormat) + saConfig := SetServiceAccountOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + + local: false, + IOStreams: streams, + } + err := saConfig.Complete(tf, cmd, input.args) + assert.NoError(t, err) + err = saConfig.Run() + assert.NoError(t, err) + }) + } +} + +func TestServiceAccountValidation(t *testing.T) { + inputs := []struct { + name string + args []string + errorString string + }{ + {name: "test service account missing", args: []string{}, errorString: serviceAccountMissingErrString}, + {name: "test service account resource missing", args: []string{serviceAccount}, errorString: resourceMissingErrString}, + } + for _, input := range inputs { + t.Run(input.name, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{ + GroupVersion: schema.GroupVersion{Version: "v1"}, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + t.Fatalf("unexpected request: %s %#v\n%#v", req.Method, req.URL, req) + return nil, nil + }), + } + + outputFormat := "" + + streams := genericclioptions.NewTestIOStreamsDiscard() + cmd := NewCmdServiceAccount(tf, streams) + + saConfig := &SetServiceAccountOptions{ + PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput(outputFormat).WithTypeSetter(scheme.Scheme), + IOStreams: streams, + } + err := saConfig.Complete(tf, cmd, input.args) + assert.EqualError(t, err, input.errorString) + }) + } +} + +func objBody(obj runtime.Object) io.ReadCloser { + return cmdtesting.BytesBody([]byte(runtime.EncodeOrDie(scheme.DefaultJSONEncoder(), obj))) +} diff --git a/pkg/cmd/set/set_subject.go b/pkg/cmd/set/set_subject.go new file mode 100644 index 000000000..df77254b7 --- /dev/null +++ b/pkg/cmd/set/set_subject.go @@ -0,0 +1,310 @@ +/* +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 set + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + subjectLong = templates.LongDesc(` + Update User, Group or ServiceAccount in a RoleBinding/ClusterRoleBinding.`) + + subjectExample = templates.Examples(` + # Update a ClusterRoleBinding for serviceaccount1 + kubectl set subject clusterrolebinding admin --serviceaccount=namespace:serviceaccount1 + + # Update a RoleBinding for user1, user2, and group1 + kubectl set subject rolebinding admin --user=user1 --user=user2 --group=group1 + + # Print the result (in yaml format) of updating rolebinding subjects from a local, without hitting the server + kubectl create rolebinding admin --role=admin --user=admin -o yaml --dry-run | kubectl set subject --local -f - --user=foo -o yaml`) +) + +type updateSubjects func(existings []rbacv1.Subject, targets []rbacv1.Subject) (bool, []rbacv1.Subject) + +// SubjectOptions is the start of the data required to perform the operation. As new fields are added, add them here instead of +// referencing the cmd.Flags +type SubjectOptions struct { + PrintFlags *genericclioptions.PrintFlags + + resource.FilenameOptions + + Infos []*resource.Info + Selector string + ContainerSelector string + Output string + All bool + DryRun bool + Local bool + + Users []string + Groups []string + ServiceAccounts []string + + namespace string + + PrintObj printers.ResourcePrinterFunc + + genericclioptions.IOStreams +} + +// NewSubjectOptions returns an initialized SubjectOptions instance +func NewSubjectOptions(streams genericclioptions.IOStreams) *SubjectOptions { + return &SubjectOptions{ + PrintFlags: genericclioptions.NewPrintFlags("subjects updated").WithTypeSetter(scheme.Scheme), + + IOStreams: streams, + } +} + +// NewCmdSubject returns the "new subject" sub command +func NewCmdSubject(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + o := NewSubjectOptions(streams) + cmd := &cobra.Command{ + Use: "subject (-f FILENAME | TYPE NAME) [--user=username] [--group=groupname] [--serviceaccount=namespace:serviceaccountname] [--dry-run]", + DisableFlagsInUseLine: true, + Short: i18n.T("Update User, Group or ServiceAccount in a RoleBinding/ClusterRoleBinding"), + Long: subjectLong, + Example: subjectExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run(addSubjects)) + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmdutil.AddFilenameOptionFlags(cmd, &o.FilenameOptions, "the resource to update the subjects") + cmd.Flags().BoolVar(&o.All, "all", o.All, "Select all resources, including uninitialized ones, in the namespace of the specified resource types") + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on, not including uninitialized ones, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().BoolVar(&o.Local, "local", o.Local, "If true, set subject will NOT contact api-server but run locally.") + cmdutil.AddDryRunFlag(cmd) + cmd.Flags().StringArrayVar(&o.Users, "user", o.Users, "Usernames to bind to the role") + cmd.Flags().StringArrayVar(&o.Groups, "group", o.Groups, "Groups to bind to the role") + cmd.Flags().StringArrayVar(&o.ServiceAccounts, "serviceaccount", o.ServiceAccounts, "Service accounts to bind to the role") + cmdutil.AddIncludeUninitializedFlag(cmd) + return cmd +} + +// Complete completes all required options +func (o *SubjectOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + o.Output = cmdutil.GetFlagString(cmd, "output") + o.DryRun = cmdutil.GetDryRunFlag(cmd) + + if o.DryRun { + o.PrintFlags.Complete("%s (dry run)") + } + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + o.PrintObj = printer.PrintObj + + var enforceNamespace bool + o.namespace, enforceNamespace, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + builder := f.NewBuilder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + LocalParam(o.Local). + ContinueOnError(). + NamespaceParam(o.namespace).DefaultNamespace(). + FilenameParam(enforceNamespace, &o.FilenameOptions). + Flatten() + + if o.Local { + // if a --local flag was provided, and a resource was specified in the form + // /, fail immediately as --local cannot query the api server + // for the specified resource. + if len(args) > 0 { + return resource.LocalResourceError + } + } else { + builder = builder. + LabelSelectorParam(o.Selector). + ResourceTypeOrNameArgs(o.All, args...). + Latest() + } + + o.Infos, err = builder.Do().Infos() + if err != nil { + return err + } + + return nil +} + +// Validate makes sure provided values in SubjectOptions are valid +func (o *SubjectOptions) Validate() error { + if o.All && len(o.Selector) > 0 { + return fmt.Errorf("cannot set --all and --selector at the same time") + } + if len(o.Users) == 0 && len(o.Groups) == 0 && len(o.ServiceAccounts) == 0 { + return fmt.Errorf("you must specify at least one value of user, group or serviceaccount") + } + + for _, sa := range o.ServiceAccounts { + tokens := strings.Split(sa, ":") + if len(tokens) != 2 || tokens[1] == "" { + return fmt.Errorf("serviceaccount must be :") + } + + for _, info := range o.Infos { + _, ok := info.Object.(*rbacv1.ClusterRoleBinding) + if ok && tokens[0] == "" { + return fmt.Errorf("serviceaccount must be :, namespace must be specified") + } + } + } + + return nil +} + +// Run performs the execution of "set subject" sub command +func (o *SubjectOptions) Run(fn updateSubjects) error { + patches := CalculatePatches(o.Infos, scheme.DefaultJSONEncoder(), func(obj runtime.Object) ([]byte, error) { + subjects := []rbacv1.Subject{} + for _, user := range sets.NewString(o.Users...).List() { + subject := rbacv1.Subject{ + Kind: rbacv1.UserKind, + APIGroup: rbacv1.GroupName, + Name: user, + } + subjects = append(subjects, subject) + } + for _, group := range sets.NewString(o.Groups...).List() { + subject := rbacv1.Subject{ + Kind: rbacv1.GroupKind, + APIGroup: rbacv1.GroupName, + Name: group, + } + subjects = append(subjects, subject) + } + for _, sa := range sets.NewString(o.ServiceAccounts...).List() { + tokens := strings.Split(sa, ":") + namespace := tokens[0] + name := tokens[1] + if len(namespace) == 0 { + namespace = o.namespace + } + subject := rbacv1.Subject{ + Kind: rbacv1.ServiceAccountKind, + Namespace: namespace, + Name: name, + } + subjects = append(subjects, subject) + } + + transformed, err := updateSubjectForObject(obj, subjects, fn) + if transformed && err == nil { + // TODO: switch UpdatePodSpecForObject to work on v1.PodSpec + return runtime.Encode(scheme.DefaultJSONEncoder(), obj) + } + return nil, err + }) + + allErrs := []error{} + for _, patch := range patches { + info := patch.Info + name := info.ObjectName() + if patch.Err != nil { + allErrs = append(allErrs, fmt.Errorf("error: %s %v\n", name, patch.Err)) + continue + } + + //no changes + if string(patch.Patch) == "{}" || len(patch.Patch) == 0 { + allErrs = append(allErrs, fmt.Errorf("info: %s was not changed\n", name)) + continue + } + + if o.Local || o.DryRun { + if err := o.PrintObj(info.Object, o.Out); err != nil { + allErrs = append(allErrs, err) + } + continue + } + + actual, err := resource.NewHelper(info.Client, info.Mapping).Patch(info.Namespace, info.Name, types.StrategicMergePatchType, patch.Patch, nil) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to patch subjects to rolebinding: %v", err)) + continue + } + + if err := o.PrintObj(actual, o.Out); err != nil { + allErrs = append(allErrs, err) + } + } + return utilerrors.NewAggregate(allErrs) +} + +//Note: the obj mutates in the function +func updateSubjectForObject(obj runtime.Object, subjects []rbacv1.Subject, fn updateSubjects) (bool, error) { + switch t := obj.(type) { + case *rbacv1.RoleBinding: + transformed, result := fn(t.Subjects, subjects) + t.Subjects = result + return transformed, nil + case *rbacv1.ClusterRoleBinding: + transformed, result := fn(t.Subjects, subjects) + t.Subjects = result + return transformed, nil + default: + return false, fmt.Errorf("setting subjects is only supported for RoleBinding/ClusterRoleBinding") + } +} + +func addSubjects(existings []rbacv1.Subject, targets []rbacv1.Subject) (bool, []rbacv1.Subject) { + transformed := false + updated := existings + for _, item := range targets { + if !contain(existings, item) { + updated = append(updated, item) + transformed = true + } + } + return transformed, updated +} + +func contain(slice []rbacv1.Subject, item rbacv1.Subject) bool { + for _, v := range slice { + if v == item { + return true + } + } + return false +} diff --git a/pkg/cmd/set/set_subject_test.go b/pkg/cmd/set/set_subject_test.go new file mode 100644 index 000000000..00ea2b0ab --- /dev/null +++ b/pkg/cmd/set/set_subject_test.go @@ -0,0 +1,426 @@ +/* +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 set + +import ( + "reflect" + "testing" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/resource" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func TestValidate(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + tests := map[string]struct { + options *SubjectOptions + expectErr bool + }{ + "test-missing-subjects": { + options: &SubjectOptions{ + Users: []string{}, + Groups: []string{}, + ServiceAccounts: []string{}, + }, + expectErr: true, + }, + "test-invalid-serviceaccounts": { + options: &SubjectOptions{ + Users: []string{}, + Groups: []string{}, + ServiceAccounts: []string{"foo"}, + }, + expectErr: true, + }, + "test-missing-serviceaccounts-name": { + options: &SubjectOptions{ + Users: []string{}, + Groups: []string{}, + ServiceAccounts: []string{"foo:"}, + }, + expectErr: true, + }, + "test-missing-serviceaccounts-namespace": { + options: &SubjectOptions{ + Infos: []*resource.Info{ + { + Object: &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterrolebinding", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "role", + }, + }, + }, + }, + Users: []string{}, + Groups: []string{}, + ServiceAccounts: []string{":foo"}, + }, + expectErr: true, + }, + "test-valid-case": { + options: &SubjectOptions{ + Infos: []*resource.Info{ + { + Object: &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rolebinding", + Namespace: "one", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "role", + }, + }, + }, + }, + Users: []string{"foo"}, + Groups: []string{"foo"}, + ServiceAccounts: []string{"ns:foo"}, + }, + expectErr: false, + }, + } + + for name, test := range tests { + err := test.options.Validate() + if test.expectErr && err != nil { + continue + } + if !test.expectErr && err != nil { + t.Errorf("%s: unexpected error: %v", name, err) + } + } +} + +func TestUpdateSubjectForObject(t *testing.T) { + tests := []struct { + Name string + obj runtime.Object + subjects []rbacv1.Subject + expected []rbacv1.Subject + wantErr bool + }{ + { + Name: "invalid object type", + obj: &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "role", + Namespace: "one", + }, + }, + wantErr: true, + }, + { + Name: "add resource with users in rolebinding", + obj: &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rolebinding", + Namespace: "one", + }, + Subjects: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "a", + }, + }, + }, + subjects: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "a", + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "b", + }, + }, + expected: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "a", + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "b", + }, + }, + wantErr: false, + }, + { + Name: "add resource with groups in rolebinding", + obj: &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rolebinding", + Namespace: "one", + }, + Subjects: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: "a", + }, + }, + }, + subjects: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: "a", + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: "b", + }, + }, + expected: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: "a", + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: "b", + }, + }, + wantErr: false, + }, + { + Name: "add resource with serviceaccounts in rolebinding", + obj: &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rolebinding", + Namespace: "one", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "a", + }, + }, + }, + subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "a", + }, + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "b", + }, + }, + expected: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "a", + }, + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "b", + }, + }, + wantErr: false, + }, + { + Name: "add resource with serviceaccounts in clusterrolebinding", + obj: &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clusterrolebinding", + }, + Subjects: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "a", + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: "a", + }, + }, + }, + subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "a", + }, + }, + expected: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "a", + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: "a", + }, + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "a", + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + if _, err := updateSubjectForObject(tt.obj, tt.subjects, addSubjects); (err != nil) != tt.wantErr { + t.Errorf("%q. updateSubjectForObject() error = %v, wantErr %v", tt.Name, err, tt.wantErr) + } + + want := tt.expected + var got []rbacv1.Subject + switch t := tt.obj.(type) { + case *rbacv1.RoleBinding: + got = t.Subjects + case *rbacv1.ClusterRoleBinding: + got = t.Subjects + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%q. updateSubjectForObject() failed", tt.Name) + t.Errorf("Got: %v", got) + t.Errorf("Want: %v", want) + } + } +} + +func TestAddSubject(t *testing.T) { + tests := []struct { + Name string + existing []rbacv1.Subject + subjects []rbacv1.Subject + expected []rbacv1.Subject + wantChange bool + }{ + { + Name: "add resource with users", + existing: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "a", + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "b", + }, + }, + subjects: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "a", + }, + }, + expected: []rbacv1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "a", + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "User", + Name: "b", + }, + }, + wantChange: false, + }, + { + Name: "add resource with serviceaccounts", + existing: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "a", + }, + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "b", + }, + }, + subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "two", + Name: "a", + }, + }, + expected: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "a", + }, + { + Kind: "ServiceAccount", + Namespace: "one", + Name: "b", + }, + { + Kind: "ServiceAccount", + Namespace: "two", + Name: "a", + }, + }, + wantChange: true, + }, + } + for _, tt := range tests { + changed := false + got := []rbacv1.Subject{} + if changed, got = addSubjects(tt.existing, tt.subjects); (changed != false) != tt.wantChange { + t.Errorf("%q. addSubjects() changed = %v, wantChange = %v", tt.Name, changed, tt.wantChange) + } + + want := tt.expected + if !reflect.DeepEqual(got, want) { + t.Errorf("%q. addSubjects() failed", tt.Name) + t.Errorf("Got: %v", got) + t.Errorf("Want: %v", want) + } + } +} diff --git a/pkg/cmd/set/set_test.go b/pkg/cmd/set/set_test.go new file mode 100644 index 000000000..d3a680895 --- /dev/null +++ b/pkg/cmd/set/set_test.go @@ -0,0 +1,45 @@ +/* +Copyright 2016 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 set + +import ( + "testing" + + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + clientcmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +func TestLocalAndDryRunFlags(t *testing.T) { + f := clientcmdutil.NewFactory(genericclioptions.NewTestConfigFlags()) + setCmd := NewCmdSet(f, genericclioptions.NewTestIOStreamsDiscard()) + ensureLocalAndDryRunFlagsOnChildren(t, setCmd, "") +} + +func ensureLocalAndDryRunFlagsOnChildren(t *testing.T, c *cobra.Command, prefix string) { + for _, cmd := range c.Commands() { + name := prefix + cmd.Name() + if localFlag := cmd.Flag("local"); localFlag == nil { + t.Errorf("Command %s does not implement the --local flag", name) + } + if dryRunFlag := cmd.Flag("dry-run"); dryRunFlag == nil { + t.Errorf("Command %s does not implement the --dry-run flag", name) + } + ensureLocalAndDryRunFlagsOnChildren(t, cmd, name+".") + } +} diff --git a/pkg/cmd/taint/taint.go b/pkg/cmd/taint/taint.go new file mode 100644 index 000000000..51e011bb5 --- /dev/null +++ b/pkg/cmd/taint/taint.go @@ -0,0 +1,306 @@ +/* +Copyright 2016 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 taint + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + "k8s.io/klog" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" +) + +// TaintOptions have the data required to perform the taint operation +type TaintOptions struct { + PrintFlags *genericclioptions.PrintFlags + ToPrinter func(string) (printers.ResourcePrinter, error) + + resources []string + taintsToAdd []v1.Taint + taintsToRemove []v1.Taint + builder *resource.Builder + selector string + overwrite bool + all bool + + ClientForMapping func(*meta.RESTMapping) (resource.RESTClient, error) + + genericclioptions.IOStreams +} + +var ( + taintLong = templates.LongDesc(i18n.T(` + Update the taints on one or more nodes. + + * A taint consists of a key, value, and effect. As an argument here, it is expressed as key=value:effect. + * The key must begin with a letter or number, and may contain letters, numbers, hyphens, dots, and underscores, up to %[1]d characters. + * Optionally, the key can begin with a DNS subdomain prefix and a single '/', like example.com/my-app + * The value is optional. If given, it must begin with a letter or number, and may contain letters, numbers, hyphens, dots, and underscores, up to %[2]d characters. + * The effect must be NoSchedule, PreferNoSchedule or NoExecute. + * Currently taint can only apply to node.`)) + + taintExample = templates.Examples(i18n.T(` + # Update node 'foo' with a taint with key 'dedicated' and value 'special-user' and effect 'NoSchedule'. + # If a taint with that key and effect already exists, its value is replaced as specified. + kubectl taint nodes foo dedicated=special-user:NoSchedule + + # Remove from node 'foo' the taint with key 'dedicated' and effect 'NoSchedule' if one exists. + kubectl taint nodes foo dedicated:NoSchedule- + + # Remove from node 'foo' all the taints with key 'dedicated' + kubectl taint nodes foo dedicated- + + # Add a taint with key 'dedicated' on nodes having label mylabel=X + kubectl taint node -l myLabel=X dedicated=foo:PreferNoSchedule + + # Add to node 'foo' a taint with key 'bar' and no value + kubectl taint nodes foo bar:NoSchedule`)) +) + +func NewCmdTaint(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + options := &TaintOptions{ + PrintFlags: genericclioptions.NewPrintFlags("tainted").WithTypeSetter(scheme.Scheme), + IOStreams: streams, + } + + validArgs := []string{"node"} + + cmd := &cobra.Command{ + Use: "taint NODE NAME KEY_1=VAL_1:TAINT_EFFECT_1 ... KEY_N=VAL_N:TAINT_EFFECT_N", + DisableFlagsInUseLine: true, + Short: i18n.T("Update the taints on one or more nodes"), + Long: fmt.Sprintf(taintLong, validation.DNS1123SubdomainMaxLength, validation.LabelValueMaxLength), + Example: taintExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(options.Complete(f, cmd, args)) + cmdutil.CheckErr(options.Validate()) + cmdutil.CheckErr(options.RunTaint()) + }, + ValidArgs: validArgs, + } + + options.PrintFlags.AddFlags(cmd) + + cmdutil.AddValidateFlags(cmd) + cmd.Flags().StringVarP(&options.selector, "selector", "l", options.selector, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().BoolVar(&options.overwrite, "overwrite", options.overwrite, "If true, allow taints to be overwritten, otherwise reject taint updates that overwrite existing taints.") + cmd.Flags().BoolVar(&options.all, "all", options.all, "Select all nodes in the cluster") + return cmd +} + +// Complete adapts from the command line args and factory to the data required. +func (o *TaintOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) (err error) { + namespace, _, err := f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + // retrieves resource and taint args from args + // also checks args to verify that all resources are specified before taints + taintArgs := []string{} + metTaintArg := false + for _, s := range args { + isTaint := strings.Contains(s, "=") || strings.HasSuffix(s, "-") + switch { + case !metTaintArg && isTaint: + metTaintArg = true + fallthrough + case metTaintArg && isTaint: + taintArgs = append(taintArgs, s) + case !metTaintArg && !isTaint: + o.resources = append(o.resources, s) + case metTaintArg && !isTaint: + return fmt.Errorf("all resources must be specified before taint changes: %s", s) + } + } + + o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { + o.PrintFlags.NamePrintFlags.Operation = operation + return o.PrintFlags.ToPrinter() + } + + if len(o.resources) < 1 { + return fmt.Errorf("one or more resources must be specified as ") + } + if len(taintArgs) < 1 { + return fmt.Errorf("at least one taint update is required") + } + + if o.taintsToAdd, o.taintsToRemove, err = parseTaints(taintArgs); err != nil { + return cmdutil.UsageErrorf(cmd, err.Error()) + } + o.builder = f.NewBuilder(). + WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...). + ContinueOnError(). + NamespaceParam(namespace).DefaultNamespace() + if o.selector != "" { + o.builder = o.builder.LabelSelectorParam(o.selector).ResourceTypes("node") + } + if o.all { + o.builder = o.builder.SelectAllParam(o.all).ResourceTypes("node").Flatten().Latest() + } + if !o.all && o.selector == "" && len(o.resources) >= 2 { + o.builder = o.builder.ResourceNames("node", o.resources[1:]...) + } + o.builder = o.builder.LabelSelectorParam(o.selector). + Flatten(). + Latest() + + o.ClientForMapping = f.ClientForMapping + return nil +} + +// validateFlags checks for the validation of flags for kubectl taints. +func (o TaintOptions) validateFlags() error { + // Cannot have a non-empty selector and all flag set. They are mutually exclusive. + if o.all && o.selector != "" { + return fmt.Errorf("setting 'all' parameter with a non empty selector is prohibited.") + } + // If both selector and all are not set. + if !o.all && o.selector == "" { + if len(o.resources) < 2 { + return fmt.Errorf("at least one resource name must be specified since 'all' parameter is not set") + } else { + return nil + } + } + return nil +} + +// Validate checks to the TaintOptions to see if there is sufficient information run the command. +func (o TaintOptions) Validate() error { + resourceType := strings.ToLower(o.resources[0]) + validResources, isValidResource := []string{"node", "nodes"}, false + for _, validResource := range validResources { + if resourceType == validResource { + isValidResource = true + break + } + } + if !isValidResource { + return fmt.Errorf("invalid resource type %s, only %q are supported", o.resources[0], validResources) + } + + // check the format of taint args and checks removed taints aren't in the new taints list + var conflictTaints []string + for _, taintAdd := range o.taintsToAdd { + for _, taintRemove := range o.taintsToRemove { + if taintAdd.Key != taintRemove.Key { + continue + } + if len(taintRemove.Effect) == 0 || taintAdd.Effect == taintRemove.Effect { + conflictTaint := fmt.Sprintf("{\"%s\":\"%s\"}", taintRemove.Key, taintRemove.Effect) + conflictTaints = append(conflictTaints, conflictTaint) + } + } + } + if len(conflictTaints) > 0 { + return fmt.Errorf("can not both modify and remove the following taint(s) in the same command: %s", strings.Join(conflictTaints, ", ")) + } + return o.validateFlags() +} + +// RunTaint does the work +func (o TaintOptions) RunTaint() error { + r := o.builder.Do() + if err := r.Err(); err != nil { + return err + } + + return r.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + obj := info.Object + name, namespace := info.Name, info.Namespace + oldData, err := json.Marshal(obj) + if err != nil { + return err + } + operation, err := o.updateTaints(obj) + if err != nil { + return err + } + newData, err := json.Marshal(obj) + if err != nil { + return err + } + patchBytes, err := strategicpatch.CreateTwoWayMergePatch(oldData, newData, obj) + createdPatch := err == nil + if err != nil { + klog.V(2).Infof("couldn't compute patch: %v", err) + } + + mapping := info.ResourceMapping() + client, err := o.ClientForMapping(mapping) + if err != nil { + return err + } + helper := resource.NewHelper(client, mapping) + + var outputObj runtime.Object + if createdPatch { + outputObj, err = helper.Patch(namespace, name, types.StrategicMergePatchType, patchBytes, nil) + } else { + outputObj, err = helper.Replace(namespace, name, false, obj) + } + if err != nil { + return err + } + + printer, err := o.ToPrinter(operation) + if err != nil { + return err + } + return printer.PrintObj(outputObj, o.Out) + }) +} + +// updateTaints applies a taint option(o) to a node in cluster after computing the net effect of operation(i.e. does it result in an overwrite?), it reports back the end result in a way that user can easily interpret. +func (o TaintOptions) updateTaints(obj runtime.Object) (string, error) { + node, ok := obj.(*v1.Node) + if !ok { + return "", fmt.Errorf("unexpected type %T, expected Node", obj) + } + if !o.overwrite { + if exists := checkIfTaintsAlreadyExists(node.Spec.Taints, o.taintsToAdd); len(exists) != 0 { + return "", fmt.Errorf("Node %s already has %v taint(s) with same effect(s) and --overwrite is false", node.Name, exists) + } + } + operation, newTaints, err := reorganizeTaints(node, o.overwrite, o.taintsToAdd, o.taintsToRemove) + if err != nil { + return "", err + } + node.Spec.Taints = newTaints + return operation, nil +} diff --git a/pkg/cmd/taint/taint_test.go b/pkg/cmd/taint/taint_test.go new file mode 100644 index 000000000..bf42d587c --- /dev/null +++ b/pkg/cmd/taint/taint_test.go @@ -0,0 +1,402 @@ +/* +Copyright 2014 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 taint + +import ( + "io/ioutil" + "net/http" + "reflect" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" +) + +func generateNodeAndTaintedNode(oldTaints []corev1.Taint, newTaints []corev1.Taint) (*corev1.Node, *corev1.Node) { + var taintedNode *corev1.Node + + // Create a node. + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node-name", + CreationTimestamp: metav1.Time{Time: time.Now()}, + }, + Spec: corev1.NodeSpec{ + Taints: oldTaints, + }, + Status: corev1.NodeStatus{}, + } + + // A copy of the same node, but tainted. + taintedNode = node.DeepCopy() + taintedNode.Spec.Taints = newTaints + + return node, taintedNode +} + +func equalTaints(taintsA, taintsB []corev1.Taint) bool { + if len(taintsA) != len(taintsB) { + return false + } + + for _, taintA := range taintsA { + found := false + for _, taintB := range taintsB { + if reflect.DeepEqual(taintA, taintB) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func TestTaint(t *testing.T) { + tests := []struct { + description string + oldTaints []corev1.Taint + newTaints []corev1.Taint + args []string + expectFatal bool + expectTaint bool + }{ + // success cases + { + description: "taints a node with effect NoSchedule", + newTaints: []corev1.Taint{{ + Key: "foo", + Value: "bar", + Effect: "NoSchedule", + }}, + args: []string{"node", "node-name", "foo=bar:NoSchedule"}, + expectFatal: false, + expectTaint: true, + }, + { + description: "taints a node with effect PreferNoSchedule", + newTaints: []corev1.Taint{{ + Key: "foo", + Value: "bar", + Effect: "PreferNoSchedule", + }}, + args: []string{"node", "node-name", "foo=bar:PreferNoSchedule"}, + expectFatal: false, + expectTaint: true, + }, + { + description: "update an existing taint on the node, change the value from bar to barz", + oldTaints: []corev1.Taint{{ + Key: "foo", + Value: "bar", + Effect: "NoSchedule", + }}, + newTaints: []corev1.Taint{{ + Key: "foo", + Value: "barz", + Effect: "NoSchedule", + }}, + args: []string{"node", "node-name", "foo=barz:NoSchedule", "--overwrite"}, + expectFatal: false, + expectTaint: true, + }, + { + description: "taints a node with two taints", + newTaints: []corev1.Taint{{ + Key: "dedicated", + Value: "namespaceA", + Effect: "NoSchedule", + }, { + Key: "foo", + Value: "bar", + Effect: "PreferNoSchedule", + }}, + args: []string{"node", "node-name", "dedicated=namespaceA:NoSchedule", "foo=bar:PreferNoSchedule"}, + expectFatal: false, + expectTaint: true, + }, + { + description: "node has two taints with the same key but different effect, remove one of them by indicating exact key and effect", + oldTaints: []corev1.Taint{{ + Key: "dedicated", + Value: "namespaceA", + Effect: "NoSchedule", + }, { + Key: "dedicated", + Value: "namespaceA", + Effect: "PreferNoSchedule", + }}, + newTaints: []corev1.Taint{{ + Key: "dedicated", + Value: "namespaceA", + Effect: "PreferNoSchedule", + }}, + args: []string{"node", "node-name", "dedicated:NoSchedule-"}, + expectFatal: false, + expectTaint: true, + }, + { + description: "node has two taints with the same key but different effect, remove all of them with wildcard", + oldTaints: []corev1.Taint{{ + Key: "dedicated", + Value: "namespaceA", + Effect: "NoSchedule", + }, { + Key: "dedicated", + Value: "namespaceA", + Effect: "PreferNoSchedule", + }}, + newTaints: []corev1.Taint{}, + args: []string{"node", "node-name", "dedicated-"}, + expectFatal: false, + expectTaint: true, + }, + { + description: "node has two taints, update one of them and remove the other", + oldTaints: []corev1.Taint{{ + Key: "dedicated", + Value: "namespaceA", + Effect: "NoSchedule", + }, { + Key: "foo", + Value: "bar", + Effect: "PreferNoSchedule", + }}, + newTaints: []corev1.Taint{{ + Key: "foo", + Value: "barz", + Effect: "PreferNoSchedule", + }}, + args: []string{"node", "node-name", "dedicated:NoSchedule-", "foo=barz:PreferNoSchedule", "--overwrite"}, + expectFatal: false, + expectTaint: true, + }, + + // error cases + { + description: "invalid taint key", + args: []string{"node", "node-name", "nospecialchars^@=banana:NoSchedule"}, + expectFatal: true, + expectTaint: false, + }, + { + description: "invalid taint effect", + args: []string{"node", "node-name", "foo=bar:NoExcute"}, + expectFatal: true, + expectTaint: false, + }, + { + description: "duplicated taints with the same key and effect should be rejected", + args: []string{"node", "node-name", "foo=bar:NoExcute", "foo=barz:NoExcute"}, + expectFatal: true, + expectTaint: false, + }, + { + description: "can't update existing taint on the node, since 'overwrite' flag is not set", + oldTaints: []corev1.Taint{{ + Key: "foo", + Value: "bar", + Effect: "NoSchedule", + }}, + newTaints: []corev1.Taint{{ + Key: "foo", + Value: "bar", + Effect: "NoSchedule", + }}, + args: []string{"node", "node-name", "foo=bar:NoSchedule"}, + expectFatal: true, + expectTaint: false, + }, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + oldNode, expectNewNode := generateNodeAndTaintedNode(test.oldTaints, test.newTaints) + newNode := &corev1.Node{} + tainted := false + tf := cmdtesting.NewTestFactory() + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + GroupVersion: corev1.SchemeGroupVersion, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + m := &MyReq{req} + switch { + case m.isFor("GET", "/nodes"): + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, oldNode)}, nil + case m.isFor("GET", "/nodes/node-name"): + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, oldNode)}, nil + case m.isFor("PATCH", "/nodes/node-name"): + tainted = true + data, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + defer req.Body.Close() + + // apply the patch + oldJSON, err := runtime.Encode(codec, oldNode) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + appliedPatch, err := strategicpatch.StrategicMergePatch(oldJSON, data, &corev1.Node{}) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + + // decode the patch + if err := runtime.DecodeInto(codec, appliedPatch, newNode); err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + if !equalTaints(expectNewNode.Spec.Taints, newNode.Spec.Taints) { + t.Fatalf("%s: expected:\n%v\nsaw:\n%v\n", test.description, expectNewNode.Spec.Taints, newNode.Spec.Taints) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, newNode)}, nil + case m.isFor("PUT", "/nodes/node-name"): + tainted = true + data, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + defer req.Body.Close() + if err := runtime.DecodeInto(codec, data, newNode); err != nil { + t.Fatalf("%s: unexpected error: %v", test.description, err) + } + if !equalTaints(expectNewNode.Spec.Taints, newNode.Spec.Taints) { + t.Fatalf("%s: expected:\n%v\nsaw:\n%v\n", test.description, expectNewNode.Spec.Taints, newNode.Spec.Taints) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, newNode)}, nil + default: + t.Fatalf("%s: unexpected request: %v %#v\n%#v", test.description, req.Method, req.URL, req) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + cmd := NewCmdTaint(tf, genericclioptions.NewTestIOStreamsDiscard()) + + sawFatal := false + func() { + defer func() { + // Recover from the panic below. + if r := recover(); r != nil { + t.Logf("Recovered: %v", r) + } + + // Restore cmdutil behavior + cmdutil.DefaultBehaviorOnFatal() + }() + cmdutil.BehaviorOnFatal(func(e string, code int) { sawFatal = true; panic(e) }) + cmd.SetArgs(test.args) + cmd.Execute() + }() + + if test.expectFatal { + if !sawFatal { + t.Fatalf("%s: unexpected non-error", test.description) + } + } + + if test.expectTaint { + if !tainted { + t.Fatalf("%s: node not tainted", test.description) + } + } + if !test.expectTaint { + if tainted { + t.Fatalf("%s: unexpected taint", test.description) + } + } + }) + } +} + +func TestValidateFlags(t *testing.T) { + tests := []struct { + taintOpts TaintOptions + description string + expectFatal bool + }{ + + { + taintOpts: TaintOptions{selector: "myLabel=X", all: false}, + description: "With Selector and without All flag", + expectFatal: false, + }, + { + taintOpts: TaintOptions{selector: "", all: true}, + description: "Without selector and All flag", + expectFatal: false, + }, + { + taintOpts: TaintOptions{selector: "myLabel=X", all: true}, + description: "With Selector and with All flag", + expectFatal: true, + }, + { + taintOpts: TaintOptions{selector: "", all: false, resources: []string{"node"}}, + description: "Without Selector and All flags and if node name is not provided", + expectFatal: true, + }, + { + taintOpts: TaintOptions{selector: "", all: false, resources: []string{"node", "node-name"}}, + description: "Without Selector and ALL flags and if node name is provided", + expectFatal: false, + }, + } + for _, test := range tests { + sawFatal := false + err := test.taintOpts.validateFlags() + if err != nil { + sawFatal = true + } + if test.expectFatal { + if !sawFatal { + t.Fatalf("%s expected not to fail", test.description) + } + } + } +} + +type MyReq struct { + Request *http.Request +} + +func (m *MyReq) isFor(method string, path string) bool { + req := m.Request + + return method == req.Method && (req.URL.Path == path || + req.URL.Path == strings.Join([]string{"/api/v1", path}, "") || + req.URL.Path == strings.Join([]string{"/apis/extensions/v1beta1", path}, "") || + req.URL.Path == strings.Join([]string{"/apis/batch/v1", path}, "")) +} diff --git a/pkg/cmd/taint/utils.go b/pkg/cmd/taint/utils.go new file mode 100644 index 000000000..d3ec76d40 --- /dev/null +++ b/pkg/cmd/taint/utils.go @@ -0,0 +1,219 @@ +/* +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 taints implements utilites for working with taints +package taint + +import ( + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation" +) + +// Exported taint constant strings +const ( + MODIFIED = "modified" + TAINTED = "tainted" + UNTAINTED = "untainted" +) + +// parseTaints takes a spec which is an array and creates slices for new taints to be added, taints to be deleted. +// It also validates the spec. For example, the form `` may be used to remove a taint, but not to add one. +func parseTaints(spec []string) ([]corev1.Taint, []corev1.Taint, error) { + var taints, taintsToRemove []corev1.Taint + uniqueTaints := map[corev1.TaintEffect]sets.String{} + + for _, taintSpec := range spec { + if strings.HasSuffix(taintSpec, "-") { + taintToRemove, err := parseTaint(strings.TrimSuffix(taintSpec, "-")) + if err != nil { + return nil, nil, err + } + taintsToRemove = append(taintsToRemove, corev1.Taint{Key: taintToRemove.Key, Effect: taintToRemove.Effect}) + } else { + newTaint, err := parseTaint(taintSpec) + if err != nil { + return nil, nil, err + } + // validate that the taint has an effect, which is required to add the taint + if len(newTaint.Effect) == 0 { + return nil, nil, fmt.Errorf("invalid taint spec: %v", taintSpec) + } + // validate if taint is unique by + if len(uniqueTaints[newTaint.Effect]) > 0 && uniqueTaints[newTaint.Effect].Has(newTaint.Key) { + return nil, nil, fmt.Errorf("duplicated taints with the same key and effect: %v", newTaint) + } + // add taint to existingTaints for uniqueness check + if len(uniqueTaints[newTaint.Effect]) == 0 { + uniqueTaints[newTaint.Effect] = sets.String{} + } + uniqueTaints[newTaint.Effect].Insert(newTaint.Key) + + taints = append(taints, newTaint) + } + } + return taints, taintsToRemove, nil +} + +// parseTaint parses a taint from a string, whose form must be either +// '=:', ':', or ''. +func parseTaint(st string) (corev1.Taint, error) { + var taint corev1.Taint + + var key string + var value string + var effect corev1.TaintEffect + + parts := strings.Split(st, ":") + switch len(parts) { + case 1: + key = parts[0] + case 2: + effect = corev1.TaintEffect(parts[1]) + if err := validateTaintEffect(effect); err != nil { + return taint, err + } + + partsKV := strings.Split(parts[0], "=") + if len(partsKV) > 2 { + return taint, fmt.Errorf("invalid taint spec: %v", st) + } + key = partsKV[0] + if len(partsKV) == 2 { + value = partsKV[1] + if errs := validation.IsValidLabelValue(value); len(errs) > 0 { + return taint, fmt.Errorf("invalid taint spec: %v, %s", st, strings.Join(errs, "; ")) + } + } + default: + return taint, fmt.Errorf("invalid taint spec: %v", st) + } + + if errs := validation.IsQualifiedName(key); len(errs) > 0 { + return taint, fmt.Errorf("invalid taint spec: %v, %s", st, strings.Join(errs, "; ")) + } + + taint.Key = key + taint.Value = value + taint.Effect = effect + + return taint, nil +} + +func validateTaintEffect(effect corev1.TaintEffect) error { + if effect != corev1.TaintEffectNoSchedule && effect != corev1.TaintEffectPreferNoSchedule && effect != corev1.TaintEffectNoExecute { + return fmt.Errorf("invalid taint effect: %v, unsupported taint effect", effect) + } + + return nil +} + +// ReorganizeTaints returns the updated set of taints, taking into account old taints that were not updated, +// old taints that were updated, old taints that were deleted, and new taints. +func reorganizeTaints(node *corev1.Node, overwrite bool, taintsToAdd []corev1.Taint, taintsToRemove []corev1.Taint) (string, []corev1.Taint, error) { + newTaints := append([]corev1.Taint{}, taintsToAdd...) + oldTaints := node.Spec.Taints + // add taints that already existing but not updated to newTaints + added := addTaints(oldTaints, &newTaints) + allErrs, deleted := deleteTaints(taintsToRemove, &newTaints) + if (added && deleted) || overwrite { + return MODIFIED, newTaints, utilerrors.NewAggregate(allErrs) + } else if added { + return TAINTED, newTaints, utilerrors.NewAggregate(allErrs) + } + return UNTAINTED, newTaints, utilerrors.NewAggregate(allErrs) +} + +// deleteTaints deletes the given taints from the node's taintlist. +func deleteTaints(taintsToRemove []corev1.Taint, newTaints *[]corev1.Taint) ([]error, bool) { + allErrs := []error{} + var removed bool + for _, taintToRemove := range taintsToRemove { + removed = false + if len(taintToRemove.Effect) > 0 { + *newTaints, removed = deleteTaint(*newTaints, &taintToRemove) + } else { + *newTaints, removed = deleteTaintsByKey(*newTaints, taintToRemove.Key) + } + if !removed { + allErrs = append(allErrs, fmt.Errorf("taint %q not found", taintToRemove.ToString())) + } + } + return allErrs, removed +} + +// addTaints adds the newTaints list to existing ones and updates the newTaints List. +// TODO: This needs a rewrite to take only the new values instead of appended newTaints list to be consistent. +func addTaints(oldTaints []corev1.Taint, newTaints *[]corev1.Taint) bool { + for _, oldTaint := range oldTaints { + existsInNew := false + for _, taint := range *newTaints { + if taint.MatchTaint(&oldTaint) { + existsInNew = true + break + } + } + if !existsInNew { + *newTaints = append(*newTaints, oldTaint) + } + } + return len(oldTaints) != len(*newTaints) +} + +// CheckIfTaintsAlreadyExists checks if the node already has taints that we want to add and returns a string with taint keys. +func checkIfTaintsAlreadyExists(oldTaints []corev1.Taint, taints []corev1.Taint) string { + var existingTaintList = make([]string, 0) + for _, taint := range taints { + for _, oldTaint := range oldTaints { + if taint.Key == oldTaint.Key && taint.Effect == oldTaint.Effect { + existingTaintList = append(existingTaintList, taint.Key) + } + } + } + return strings.Join(existingTaintList, ",") +} + +// DeleteTaintsByKey removes all the taints that have the same key to given taintKey +func deleteTaintsByKey(taints []corev1.Taint, taintKey string) ([]corev1.Taint, bool) { + newTaints := []corev1.Taint{} + deleted := false + for i := range taints { + if taintKey == taints[i].Key { + deleted = true + continue + } + newTaints = append(newTaints, taints[i]) + } + return newTaints, deleted +} + +// DeleteTaint removes all the taints that have the same key and effect to given taintToDelete. +func deleteTaint(taints []corev1.Taint, taintToDelete *corev1.Taint) ([]corev1.Taint, bool) { + newTaints := []corev1.Taint{} + deleted := false + for i := range taints { + if taintToDelete.MatchTaint(&taints[i]) { + deleted = true + continue + } + newTaints = append(newTaints, taints[i]) + } + return newTaints, deleted +} diff --git a/pkg/cmd/taint/utils_test.go b/pkg/cmd/taint/utils_test.go new file mode 100644 index 000000000..e3dc1ac0a --- /dev/null +++ b/pkg/cmd/taint/utils_test.go @@ -0,0 +1,533 @@ +/* +Copyright 2016 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 taint + +import ( + "reflect" + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestDeleteTaint(t *testing.T) { + cases := []struct { + name string + taints []corev1.Taint + taintToDelete *corev1.Taint + expectedTaints []corev1.Taint + expectedResult bool + }{ + { + name: "delete taint with different name", + taints: []corev1.Taint{ + { + Key: "foo", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + taintToDelete: &corev1.Taint{Key: "foo_1", Effect: corev1.TaintEffectNoSchedule}, + expectedTaints: []corev1.Taint{ + { + Key: "foo", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedResult: false, + }, + { + name: "delete taint with different effect", + taints: []corev1.Taint{ + { + Key: "foo", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + taintToDelete: &corev1.Taint{Key: "foo", Effect: corev1.TaintEffectNoExecute}, + expectedTaints: []corev1.Taint{ + { + Key: "foo", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedResult: false, + }, + { + name: "delete taint successfully", + taints: []corev1.Taint{ + { + Key: "foo", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + taintToDelete: &corev1.Taint{Key: "foo", Effect: corev1.TaintEffectNoSchedule}, + expectedTaints: []corev1.Taint{}, + expectedResult: true, + }, + { + name: "delete taint from empty taint array", + taints: []corev1.Taint{}, + taintToDelete: &corev1.Taint{Key: "foo", Effect: corev1.TaintEffectNoSchedule}, + expectedTaints: []corev1.Taint{}, + expectedResult: false, + }, + } + + for _, c := range cases { + taints, result := deleteTaint(c.taints, c.taintToDelete) + if result != c.expectedResult { + t.Errorf("[%s] should return %t, but got: %t", c.name, c.expectedResult, result) + } + if !reflect.DeepEqual(taints, c.expectedTaints) { + t.Errorf("[%s] the result taints should be %v, but got: %v", c.name, c.expectedTaints, taints) + } + } +} + +func TestDeleteTaintByKey(t *testing.T) { + cases := []struct { + name string + taints []corev1.Taint + taintKey string + expectedTaints []corev1.Taint + expectedResult bool + }{ + { + name: "delete taint unsuccessfully", + taints: []corev1.Taint{ + { + Key: "foo", + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + taintKey: "foo_1", + expectedTaints: []corev1.Taint{ + { + Key: "foo", + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedResult: false, + }, + { + name: "delete taint successfully", + taints: []corev1.Taint{ + { + Key: "foo", + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + taintKey: "foo", + expectedTaints: []corev1.Taint{}, + expectedResult: true, + }, + { + name: "delete taint from empty taint array", + taints: []corev1.Taint{}, + taintKey: "foo", + expectedTaints: []corev1.Taint{}, + expectedResult: false, + }, + } + + for _, c := range cases { + taints, result := deleteTaintsByKey(c.taints, c.taintKey) + if result != c.expectedResult { + t.Errorf("[%s] should return %t, but got: %t", c.name, c.expectedResult, result) + } + if !reflect.DeepEqual(c.expectedTaints, taints) { + t.Errorf("[%s] the result taints should be %v, but got: %v", c.name, c.expectedTaints, taints) + } + } +} + +func TestCheckIfTaintsAlreadyExists(t *testing.T) { + oldTaints := []corev1.Taint{ + { + Key: "foo_1", + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "foo_2", + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "foo_3", + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + } + + cases := []struct { + name string + taintsToCheck []corev1.Taint + expectedResult string + }{ + { + name: "empty array", + taintsToCheck: []corev1.Taint{}, + expectedResult: "", + }, + { + name: "no match", + taintsToCheck: []corev1.Taint{ + { + Key: "foo_1", + Effect: corev1.TaintEffectNoExecute, + }, + }, + expectedResult: "", + }, + { + name: "match one taint", + taintsToCheck: []corev1.Taint{ + { + Key: "foo_2", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedResult: "foo_2", + }, + { + name: "match two taints", + taintsToCheck: []corev1.Taint{ + { + Key: "foo_2", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "foo_3", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedResult: "foo_2,foo_3", + }, + } + + for _, c := range cases { + result := checkIfTaintsAlreadyExists(oldTaints, c.taintsToCheck) + if result != c.expectedResult { + t.Errorf("[%s] should return '%s', but got: '%s'", c.name, c.expectedResult, result) + } + } +} + +func TestReorganizeTaints(t *testing.T) { + node := &corev1.Node{ + Spec: corev1.NodeSpec{ + Taints: []corev1.Taint{ + { + Key: "foo", + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + }, + } + + cases := []struct { + name string + overwrite bool + taintsToAdd []corev1.Taint + taintsToDelete []corev1.Taint + expectedTaints []corev1.Taint + expectedOperation string + expectedErr bool + }{ + { + name: "no changes with overwrite is true", + overwrite: true, + taintsToAdd: []corev1.Taint{}, + taintsToDelete: []corev1.Taint{}, + expectedTaints: node.Spec.Taints, + expectedOperation: MODIFIED, + expectedErr: false, + }, + { + name: "no changes with overwrite is false", + overwrite: false, + taintsToAdd: []corev1.Taint{}, + taintsToDelete: []corev1.Taint{}, + expectedTaints: node.Spec.Taints, + expectedOperation: UNTAINTED, + expectedErr: false, + }, + { + name: "add new taint", + overwrite: false, + taintsToAdd: []corev1.Taint{ + { + Key: "foo_1", + Effect: corev1.TaintEffectNoExecute, + }, + }, + taintsToDelete: []corev1.Taint{}, + expectedTaints: append([]corev1.Taint{{Key: "foo_1", Effect: corev1.TaintEffectNoExecute}}, node.Spec.Taints...), + expectedOperation: TAINTED, + expectedErr: false, + }, + { + name: "delete taint with effect", + overwrite: false, + taintsToAdd: []corev1.Taint{}, + taintsToDelete: []corev1.Taint{ + { + Key: "foo", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedTaints: []corev1.Taint{}, + expectedOperation: UNTAINTED, + expectedErr: false, + }, + { + name: "delete taint with no effect", + overwrite: false, + taintsToAdd: []corev1.Taint{}, + taintsToDelete: []corev1.Taint{ + { + Key: "foo", + }, + }, + expectedTaints: []corev1.Taint{}, + expectedOperation: UNTAINTED, + expectedErr: false, + }, + { + name: "delete non-exist taint", + overwrite: false, + taintsToAdd: []corev1.Taint{}, + taintsToDelete: []corev1.Taint{ + { + Key: "foo_1", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedTaints: node.Spec.Taints, + expectedOperation: UNTAINTED, + expectedErr: true, + }, + { + name: "add new taint and delete old one", + overwrite: false, + taintsToAdd: []corev1.Taint{ + { + Key: "foo_1", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + taintsToDelete: []corev1.Taint{ + { + Key: "foo", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedTaints: []corev1.Taint{ + { + Key: "foo_1", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedOperation: MODIFIED, + expectedErr: false, + }, + } + + for _, c := range cases { + operation, taints, err := reorganizeTaints(node, c.overwrite, c.taintsToAdd, c.taintsToDelete) + if c.expectedErr && err == nil { + t.Errorf("[%s] expect to see an error, but did not get one", c.name) + } else if !c.expectedErr && err != nil { + t.Errorf("[%s] expect not to see an error, but got one: %v", c.name, err) + } + + if !reflect.DeepEqual(c.expectedTaints, taints) { + t.Errorf("[%s] expect to see taint list %#v, but got: %#v", c.name, c.expectedTaints, taints) + } + + if c.expectedOperation != operation { + t.Errorf("[%s] expect to see operation %s, but got: %s", c.name, c.expectedOperation, operation) + } + } +} + +func TestParseTaints(t *testing.T) { + cases := []struct { + name string + spec []string + expectedTaints []corev1.Taint + expectedTaintsToRemove []corev1.Taint + expectedErr bool + }{ + { + name: "invalid spec format", + spec: []string{""}, + expectedErr: true, + }, + { + name: "invalid spec format", + spec: []string{"foo=abc"}, + expectedErr: true, + }, + { + name: "invalid spec format", + spec: []string{"foo=abc=xyz:NoSchedule"}, + expectedErr: true, + }, + { + name: "invalid spec format", + spec: []string{"foo=abc:xyz:NoSchedule"}, + expectedErr: true, + }, + { + name: "invalid spec format for adding taint", + spec: []string{"foo"}, + expectedErr: true, + }, + { + name: "invalid spec effect for adding taint", + spec: []string{"foo=abc:invalid_effect"}, + expectedErr: true, + }, + { + name: "invalid spec effect for deleting taint", + spec: []string{"foo:invalid_effect-"}, + expectedErr: true, + }, + { + name: "add new taints", + spec: []string{"foo=abc:NoSchedule", "bar=abc:NoSchedule", "baz:NoSchedule", "qux:NoSchedule", "foobar=:NoSchedule"}, + expectedTaints: []corev1.Taint{ + { + Key: "foo", + Value: "abc", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "bar", + Value: "abc", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "baz", + Value: "", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "qux", + Value: "", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "foobar", + Value: "", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedErr: false, + }, + { + name: "delete taints", + spec: []string{"foo:NoSchedule-", "bar:NoSchedule-", "qux=:NoSchedule-", "dedicated-"}, + expectedTaintsToRemove: []corev1.Taint{ + { + Key: "foo", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "qux", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "dedicated", + }, + }, + expectedErr: false, + }, + { + name: "add taints and delete taints", + spec: []string{"foo=abc:NoSchedule", "bar=abc:NoSchedule", "baz:NoSchedule", "qux:NoSchedule", "foobar=:NoSchedule", "foo:NoSchedule-", "bar:NoSchedule-", "baz=:NoSchedule-"}, + expectedTaints: []corev1.Taint{ + { + Key: "foo", + Value: "abc", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "bar", + Value: "abc", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "baz", + Value: "", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "qux", + Value: "", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "foobar", + Value: "", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedTaintsToRemove: []corev1.Taint{ + { + Key: "foo", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "baz", + Value: "", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + expectedErr: false, + }, + } + + for _, c := range cases { + taints, taintsToRemove, err := parseTaints(c.spec) + if c.expectedErr && err == nil { + t.Errorf("[%s] expected error for spec %s, but got nothing", c.name, c.spec) + } + if !c.expectedErr && err != nil { + t.Errorf("[%s] expected no error for spec %s, but got: %v", c.name, c.spec, err) + } + if !reflect.DeepEqual(c.expectedTaints, taints) { + t.Errorf("[%s] expected returen taints as %v, but got: %v", c.name, c.expectedTaints, taints) + } + if !reflect.DeepEqual(c.expectedTaintsToRemove, taintsToRemove) { + t.Errorf("[%s] expected return taints to be removed as %v, but got: %v", c.name, c.expectedTaintsToRemove, taintsToRemove) + } + } +} diff --git a/pkg/cmd/testing/fake.go b/pkg/cmd/testing/fake.go new file mode 100644 index 000000000..ac8b5edda --- /dev/null +++ b/pkg/cmd/testing/fake.go @@ -0,0 +1,668 @@ +/* +Copyright 2016 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 testing + +import ( + "bytes" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/meta/testrestmapper" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/discovery" + diskcached "k8s.io/client-go/discovery/cached/disk" + "k8s.io/client-go/dynamic" + fakedynamic "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + "k8s.io/client-go/restmapper" + scaleclient "k8s.io/client-go/scale" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util/openapi" + openapitesting "k8s.io/kubectl/pkg/util/openapi/testing" + "k8s.io/kubectl/pkg/validation" +) + +// InternalType is the schema for internal type +// +k8s:deepcopy-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type InternalType struct { + Kind string + APIVersion string + + Name string +} + +// ExternalType is the schema for external type +// +k8s:deepcopy-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ExternalType struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + + Name string `json:"name"` +} + +// ExternalType2 is another schema for external type +// +k8s:deepcopy-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ExternalType2 struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + + Name string `json:"name"` +} + +// GetObjectKind returns the ObjectKind schema +func (obj *InternalType) GetObjectKind() schema.ObjectKind { return obj } + +// SetGroupVersionKind sets the version and kind +func (obj *InternalType) SetGroupVersionKind(gvk schema.GroupVersionKind) { + obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() +} + +// GroupVersionKind returns GroupVersionKind schema +func (obj *InternalType) GroupVersionKind() schema.GroupVersionKind { + return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) +} + +// GetObjectKind returns the ObjectKind schema +func (obj *ExternalType) GetObjectKind() schema.ObjectKind { return obj } + +// SetGroupVersionKind returns the GroupVersionKind schema +func (obj *ExternalType) SetGroupVersionKind(gvk schema.GroupVersionKind) { + obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() +} + +// GroupVersionKind returns the GroupVersionKind schema +func (obj *ExternalType) GroupVersionKind() schema.GroupVersionKind { + return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) +} + +// GetObjectKind returns the ObjectKind schema +func (obj *ExternalType2) GetObjectKind() schema.ObjectKind { return obj } + +// SetGroupVersionKind sets the API version and obj kind from schema +func (obj *ExternalType2) SetGroupVersionKind(gvk schema.GroupVersionKind) { + obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() +} + +// GroupVersionKind returns the FromAPIVersionAndKind schema +func (obj *ExternalType2) GroupVersionKind() schema.GroupVersionKind { + return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) +} + +// NewInternalType returns an initialized InternalType instance +func NewInternalType(kind, apiversion, name string) *InternalType { + item := InternalType{Kind: kind, + APIVersion: apiversion, + Name: name} + return &item +} + +// InternalNamespacedType schema for internal namespaced types +// +k8s:deepcopy-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type InternalNamespacedType struct { + Kind string + APIVersion string + + Name string + Namespace string +} + +// ExternalNamespacedType schema for external namespaced types +// +k8s:deepcopy-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ExternalNamespacedType struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + + Name string `json:"name"` + Namespace string `json:"namespace"` +} + +// ExternalNamespacedType2 schema for external namespaced types +// +k8s:deepcopy-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ExternalNamespacedType2 struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + + Name string `json:"name"` + Namespace string `json:"namespace"` +} + +// GetObjectKind returns the ObjectKind schema +func (obj *InternalNamespacedType) GetObjectKind() schema.ObjectKind { return obj } + +// SetGroupVersionKind sets the API group and kind from schema +func (obj *InternalNamespacedType) SetGroupVersionKind(gvk schema.GroupVersionKind) { + obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() +} + +// GroupVersionKind returns the GroupVersionKind schema +func (obj *InternalNamespacedType) GroupVersionKind() schema.GroupVersionKind { + return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) +} + +// GetObjectKind returns the ObjectKind schema +func (obj *ExternalNamespacedType) GetObjectKind() schema.ObjectKind { return obj } + +// SetGroupVersionKind sets the API version and kind from schema +func (obj *ExternalNamespacedType) SetGroupVersionKind(gvk schema.GroupVersionKind) { + obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() +} + +// GroupVersionKind returns the GroupVersionKind schema +func (obj *ExternalNamespacedType) GroupVersionKind() schema.GroupVersionKind { + return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) +} + +// GetObjectKind returns the ObjectKind schema +func (obj *ExternalNamespacedType2) GetObjectKind() schema.ObjectKind { return obj } + +// SetGroupVersionKind sets the API version and kind from schema +func (obj *ExternalNamespacedType2) SetGroupVersionKind(gvk schema.GroupVersionKind) { + obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() +} + +// GroupVersionKind returns the GroupVersionKind schema +func (obj *ExternalNamespacedType2) GroupVersionKind() schema.GroupVersionKind { + return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) +} + +// NewInternalNamespacedType returns an initialized instance of InternalNamespacedType +func NewInternalNamespacedType(kind, apiversion, name, namespace string) *InternalNamespacedType { + item := InternalNamespacedType{Kind: kind, + APIVersion: apiversion, + Name: name, + Namespace: namespace} + return &item +} + +var errInvalidVersion = errors.New("not a version") + +// ValidVersion of API +var ValidVersion = "v1" + +// InternalGV is the internal group version object +var InternalGV = schema.GroupVersion{Group: "apitest", Version: runtime.APIVersionInternal} + +// UnlikelyGV is a group version object for unrecognised version +var UnlikelyGV = schema.GroupVersion{Group: "apitest", Version: "unlikelyversion"} + +// ValidVersionGV is the valid group version object +var ValidVersionGV = schema.GroupVersion{Group: "apitest", Version: ValidVersion} + +// NewExternalScheme returns required objects for ExternalScheme +func NewExternalScheme() (*runtime.Scheme, meta.RESTMapper, runtime.Codec) { + scheme := runtime.NewScheme() + mapper, codec := AddToScheme(scheme) + return scheme, mapper, codec +} + +// AddToScheme adds required objects into scheme +func AddToScheme(scheme *runtime.Scheme) (meta.RESTMapper, runtime.Codec) { + scheme.AddKnownTypeWithName(InternalGV.WithKind("Type"), &InternalType{}) + scheme.AddKnownTypeWithName(UnlikelyGV.WithKind("Type"), &ExternalType{}) + //This tests that kubectl will not confuse the external scheme with the internal scheme, even when they accidentally have versions of the same name. + scheme.AddKnownTypeWithName(ValidVersionGV.WithKind("Type"), &ExternalType2{}) + + scheme.AddKnownTypeWithName(InternalGV.WithKind("NamespacedType"), &InternalNamespacedType{}) + scheme.AddKnownTypeWithName(UnlikelyGV.WithKind("NamespacedType"), &ExternalNamespacedType{}) + //This tests that kubectl will not confuse the external scheme with the internal scheme, even when they accidentally have versions of the same name. + scheme.AddKnownTypeWithName(ValidVersionGV.WithKind("NamespacedType"), &ExternalNamespacedType2{}) + + codecs := serializer.NewCodecFactory(scheme) + codec := codecs.LegacyCodec(UnlikelyGV) + mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{UnlikelyGV, ValidVersionGV}) + for _, gv := range []schema.GroupVersion{UnlikelyGV, ValidVersionGV} { + for kind := range scheme.KnownTypes(gv) { + gvk := gv.WithKind(kind) + + scope := meta.RESTScopeNamespace + mapper.Add(gvk, scope) + } + } + + return mapper, codec +} + +type fakeCachedDiscoveryClient struct { + discovery.DiscoveryInterface +} + +func (d *fakeCachedDiscoveryClient) Fresh() bool { + return true +} + +func (d *fakeCachedDiscoveryClient) Invalidate() { +} + +// Deprecated: use ServerGroupsAndResources instead. +func (d *fakeCachedDiscoveryClient) ServerResources() ([]*metav1.APIResourceList, error) { + return []*metav1.APIResourceList{}, nil +} + +func (d *fakeCachedDiscoveryClient) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) { + return []*metav1.APIGroup{}, []*metav1.APIResourceList{}, nil +} + +// TestFactory extends cmdutil.Factory +type TestFactory struct { + cmdutil.Factory + + kubeConfigFlags *genericclioptions.TestConfigFlags + + Client RESTClient + ScaleGetter scaleclient.ScalesGetter + UnstructuredClient RESTClient + ClientConfigVal *restclient.Config + FakeDynamicClient *fakedynamic.FakeDynamicClient + + tempConfigFile *os.File + + UnstructuredClientForMappingFunc resource.FakeClientFunc + OpenAPISchemaFunc func() (openapi.Resources, error) +} + +// NewTestFactory returns an initialized TestFactory instance +func NewTestFactory() *TestFactory { + // specify an optionalClientConfig to explicitly use in testing + // to avoid polluting an existing user config. + tmpFile, err := ioutil.TempFile("", "cmdtests_temp") + if err != nil { + panic(fmt.Sprintf("unable to create a fake client config: %v", err)) + } + + loadingRules := &clientcmd.ClientConfigLoadingRules{ + Precedence: []string{tmpFile.Name()}, + MigrationRules: map[string]string{}, + } + + overrides := &clientcmd.ConfigOverrides{ClusterDefaults: clientcmdapi.Cluster{Server: "http://localhost:8080"}} + fallbackReader := bytes.NewBuffer([]byte{}) + clientConfig := clientcmd.NewInteractiveDeferredLoadingClientConfig(loadingRules, overrides, fallbackReader) + + configFlags := genericclioptions.NewTestConfigFlags(). + WithClientConfig(clientConfig). + WithRESTMapper(testRESTMapper()) + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + panic(fmt.Sprintf("unable to create a fake restclient config: %v", err)) + } + + return &TestFactory{ + Factory: cmdutil.NewFactory(configFlags), + kubeConfigFlags: configFlags, + FakeDynamicClient: fakedynamic.NewSimpleDynamicClient(scheme.Scheme), + tempConfigFile: tmpFile, + + ClientConfigVal: restConfig, + } +} + +// WithNamespace is used to mention namespace reactively +func (f *TestFactory) WithNamespace(ns string) *TestFactory { + f.kubeConfigFlags.WithNamespace(ns) + return f +} + +// Cleanup cleans up TestFactory temp config file +func (f *TestFactory) Cleanup() { + if f.tempConfigFile == nil { + return + } + + os.Remove(f.tempConfigFile.Name()) +} + +// ToRESTConfig is used to get ClientConfigVal from a TestFactory +func (f *TestFactory) ToRESTConfig() (*restclient.Config, error) { + return f.ClientConfigVal, nil +} + +// ClientForMapping is used to Client from a TestFactory +func (f *TestFactory) ClientForMapping(mapping *meta.RESTMapping) (resource.RESTClient, error) { + return f.Client, nil +} + +// UnstructuredClientForMapping is used to get UnstructuredClient from a TestFactory +func (f *TestFactory) UnstructuredClientForMapping(mapping *meta.RESTMapping) (resource.RESTClient, error) { + if f.UnstructuredClientForMappingFunc != nil { + return f.UnstructuredClientForMappingFunc(mapping.GroupVersionKind.GroupVersion()) + } + return f.UnstructuredClient, nil +} + +// Validator returns a validation schema +func (f *TestFactory) Validator(validate bool) (validation.Schema, error) { + return validation.NullSchema{}, nil +} + +// OpenAPISchema returns openapi resources +func (f *TestFactory) OpenAPISchema() (openapi.Resources, error) { + if f.OpenAPISchemaFunc != nil { + return f.OpenAPISchemaFunc() + } + return openapitesting.EmptyResources{}, nil +} + +// NewBuilder returns an initialized resource.Builder instance +func (f *TestFactory) NewBuilder() *resource.Builder { + return resource.NewFakeBuilder( + func(version schema.GroupVersion) (resource.RESTClient, error) { + if f.UnstructuredClientForMappingFunc != nil { + return f.UnstructuredClientForMappingFunc(version) + } + if f.UnstructuredClient != nil { + return f.UnstructuredClient, nil + } + return f.Client, nil + }, + f.ToRESTMapper, + func() (restmapper.CategoryExpander, error) { + return resource.FakeCategoryExpander, nil + }, + ) +} + +// KubernetesClientSet initializes and returns the Clientset using TestFactory +func (f *TestFactory) KubernetesClientSet() (*kubernetes.Clientset, error) { + fakeClient := f.Client.(*fake.RESTClient) + clientset := kubernetes.NewForConfigOrDie(f.ClientConfigVal) + + clientset.CoreV1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.AuthorizationV1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.AuthorizationV1beta1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.AuthorizationV1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.AuthorizationV1beta1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.AutoscalingV1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.AutoscalingV2beta1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.BatchV1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.BatchV2alpha1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.CertificatesV1beta1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.ExtensionsV1beta1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.RbacV1alpha1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.RbacV1beta1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.StorageV1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.StorageV1beta1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.AppsV1beta1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.AppsV1beta2().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.AppsV1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.PolicyV1beta1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + clientset.DiscoveryClient.RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + + return clientset, nil +} + +// DynamicClient returns a dynamic client from TestFactory +func (f *TestFactory) DynamicClient() (dynamic.Interface, error) { + if f.FakeDynamicClient != nil { + return f.FakeDynamicClient, nil + } + return f.Factory.DynamicClient() +} + +// RESTClient returns a REST client from TestFactory +func (f *TestFactory) RESTClient() (*restclient.RESTClient, error) { + // Swap out the HTTP client out of the client with the fake's version. + fakeClient := f.Client.(*fake.RESTClient) + restClient, err := restclient.RESTClientFor(f.ClientConfigVal) + if err != nil { + panic(err) + } + restClient.Client = fakeClient.Client + return restClient, nil +} + +// DiscoveryClient returns a discovery client from TestFactory +func (f *TestFactory) DiscoveryClient() (discovery.CachedDiscoveryInterface, error) { + fakeClient := f.Client.(*fake.RESTClient) + + cacheDir := filepath.Join("", ".kube", "cache", "discovery") + cachedClient, err := diskcached.NewCachedDiscoveryClientForConfig(f.ClientConfigVal, cacheDir, "", time.Duration(10*time.Minute)) + if err != nil { + return nil, err + } + cachedClient.RESTClient().(*restclient.RESTClient).Client = fakeClient.Client + + return cachedClient, nil +} + +func testRESTMapper() meta.RESTMapper { + groupResources := testDynamicResources() + mapper := restmapper.NewDiscoveryRESTMapper(groupResources) + // for backwards compatibility with existing tests, allow rest mappings from the scheme to show up + // TODO: make this opt-in? + mapper = meta.FirstHitRESTMapper{ + MultiRESTMapper: meta.MultiRESTMapper{ + mapper, + testrestmapper.TestOnlyStaticRESTMapper(scheme.Scheme), + }, + } + + fakeDs := &fakeCachedDiscoveryClient{} + expander := restmapper.NewShortcutExpander(mapper, fakeDs) + return expander +} + +// ScaleClient returns the ScalesGetter from a TestFactory +func (f *TestFactory) ScaleClient() (scaleclient.ScalesGetter, error) { + return f.ScaleGetter, nil +} + +func testDynamicResources() []*restmapper.APIGroupResources { + return []*restmapper.APIGroupResources{ + { + Group: metav1.APIGroup{ + Versions: []metav1.GroupVersionForDiscovery{ + {Version: "v1"}, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v1"}, + }, + VersionedResources: map[string][]metav1.APIResource{ + "v1": { + {Name: "pods", Namespaced: true, Kind: "Pod"}, + {Name: "services", Namespaced: true, Kind: "Service"}, + {Name: "replicationcontrollers", Namespaced: true, Kind: "ReplicationController"}, + {Name: "componentstatuses", Namespaced: false, Kind: "ComponentStatus"}, + {Name: "nodes", Namespaced: false, Kind: "Node"}, + {Name: "secrets", Namespaced: true, Kind: "Secret"}, + {Name: "configmaps", Namespaced: true, Kind: "ConfigMap"}, + {Name: "namespacedtype", Namespaced: true, Kind: "NamespacedType"}, + {Name: "namespaces", Namespaced: false, Kind: "Namespace"}, + {Name: "resourcequotas", Namespaced: true, Kind: "ResourceQuota"}, + }, + }, + }, + { + Group: metav1.APIGroup{ + Name: "extensions", + Versions: []metav1.GroupVersionForDiscovery{ + {Version: "v1beta1"}, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v1beta1"}, + }, + VersionedResources: map[string][]metav1.APIResource{ + "v1beta1": { + {Name: "deployments", Namespaced: true, Kind: "Deployment"}, + {Name: "replicasets", Namespaced: true, Kind: "ReplicaSet"}, + }, + }, + }, + { + Group: metav1.APIGroup{ + Name: "apps", + Versions: []metav1.GroupVersionForDiscovery{ + {Version: "v1beta1"}, + {Version: "v1beta2"}, + {Version: "v1"}, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v1"}, + }, + VersionedResources: map[string][]metav1.APIResource{ + "v1beta1": { + {Name: "deployments", Namespaced: true, Kind: "Deployment"}, + {Name: "replicasets", Namespaced: true, Kind: "ReplicaSet"}, + }, + "v1beta2": { + {Name: "deployments", Namespaced: true, Kind: "Deployment"}, + }, + "v1": { + {Name: "deployments", Namespaced: true, Kind: "Deployment"}, + {Name: "replicasets", Namespaced: true, Kind: "ReplicaSet"}, + }, + }, + }, + { + Group: metav1.APIGroup{ + Name: "batch", + Versions: []metav1.GroupVersionForDiscovery{ + {Version: "v1beta1"}, + {Version: "v1"}, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v1"}, + }, + VersionedResources: map[string][]metav1.APIResource{ + "v1beta1": { + {Name: "cronjobs", Namespaced: true, Kind: "CronJob"}, + }, + "v1": { + {Name: "jobs", Namespaced: true, Kind: "Job"}, + }, + }, + }, + { + Group: metav1.APIGroup{ + Name: "autoscaling", + Versions: []metav1.GroupVersionForDiscovery{ + {Version: "v1"}, + {Version: "v2beta1"}, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v2beta1"}, + }, + VersionedResources: map[string][]metav1.APIResource{ + "v1": { + {Name: "horizontalpodautoscalers", Namespaced: true, Kind: "HorizontalPodAutoscaler"}, + }, + "v2beta1": { + {Name: "horizontalpodautoscalers", Namespaced: true, Kind: "HorizontalPodAutoscaler"}, + }, + }, + }, + { + Group: metav1.APIGroup{ + Name: "storage.k8s.io", + Versions: []metav1.GroupVersionForDiscovery{ + {Version: "v1beta1"}, + {Version: "v0"}, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v1beta1"}, + }, + VersionedResources: map[string][]metav1.APIResource{ + "v1beta1": { + {Name: "storageclasses", Namespaced: false, Kind: "StorageClass"}, + }, + // bogus version of a known group/version/resource to make sure kubectl falls back to generic object mode + "v0": { + {Name: "storageclasses", Namespaced: false, Kind: "StorageClass"}, + }, + }, + }, + { + Group: metav1.APIGroup{ + Name: "rbac.authorization.k8s.io", + Versions: []metav1.GroupVersionForDiscovery{ + {Version: "v1beta1"}, + {Version: "v1"}, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v1"}, + }, + VersionedResources: map[string][]metav1.APIResource{ + "v1": { + {Name: "clusterroles", Namespaced: false, Kind: "ClusterRole"}, + }, + "v1beta1": { + {Name: "clusterrolebindings", Namespaced: false, Kind: "ClusterRoleBinding"}, + }, + }, + }, + { + Group: metav1.APIGroup{ + Name: "company.com", + Versions: []metav1.GroupVersionForDiscovery{ + {Version: "v1"}, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v1"}, + }, + VersionedResources: map[string][]metav1.APIResource{ + "v1": { + {Name: "bars", Namespaced: true, Kind: "Bar"}, + }, + }, + }, + { + Group: metav1.APIGroup{ + Name: "unit-test.test.com", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "unit-test.test.com/v1", Version: "v1"}, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "unit-test.test.com/v1", + Version: "v1"}, + }, + VersionedResources: map[string][]metav1.APIResource{ + "v1": { + {Name: "widgets", Namespaced: true, Kind: "Widget"}, + }, + }, + }, + { + Group: metav1.APIGroup{ + Name: "apitest", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "apitest/unlikelyversion", Version: "unlikelyversion"}, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "apitest/unlikelyversion", + Version: "unlikelyversion"}, + }, + VersionedResources: map[string][]metav1.APIResource{ + "unlikelyversion": { + {Name: "types", SingularName: "type", Namespaced: false, Kind: "Type"}, + }, + }, + }, + } +} diff --git a/pkg/cmd/testing/interfaces.go b/pkg/cmd/testing/interfaces.go new file mode 100644 index 000000000..efb6a65de --- /dev/null +++ b/pkg/cmd/testing/interfaces.go @@ -0,0 +1,32 @@ +/* +Copyright 2014 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 testing + +import ( + "k8s.io/apimachinery/pkg/types" + client "k8s.io/client-go/rest" +) + +// RESTClient is a client helper for dealing with RESTful resources +// in a generic way. +type RESTClient interface { + Get() *client.Request + Post() *client.Request + Patch(types.PatchType) *client.Request + Delete() *client.Request + Put() *client.Request +} diff --git a/pkg/cmd/testing/util.go b/pkg/cmd/testing/util.go new file mode 100644 index 000000000..487475280 --- /dev/null +++ b/pkg/cmd/testing/util.go @@ -0,0 +1,165 @@ +/* +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 testing + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "net/http" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + restclient "k8s.io/client-go/rest" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" +) + +var ( + grace = int64(30) + enableServiceLinks = corev1.DefaultEnableServiceLinks +) + +func DefaultHeader() http.Header { + header := http.Header{} + header.Set("Content-Type", runtime.ContentTypeJSON) + return header +} + +func DefaultClientConfig() *restclient.Config { + return &restclient.Config{ + APIPath: "/api", + ContentConfig: restclient.ContentConfig{ + NegotiatedSerializer: scheme.Codecs, + ContentType: runtime.ContentTypeJSON, + GroupVersion: &corev1.SchemeGroupVersion, + }, + } +} + +func ObjBody(codec runtime.Codec, obj runtime.Object) io.ReadCloser { + return ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(codec, obj)))) +} + +func BytesBody(bodyBytes []byte) io.ReadCloser { + return ioutil.NopCloser(bytes.NewReader(bodyBytes)) +} + +func StringBody(body string) io.ReadCloser { + return ioutil.NopCloser(bytes.NewReader([]byte(body))) +} + +func TestData() (*corev1.PodList, *corev1.ServiceList, *corev1.ReplicationControllerList) { + pods := &corev1.PodList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "15", + }, + Items: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "test", ResourceVersion: "10"}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + DNSPolicy: corev1.DNSClusterFirst, + TerminationGracePeriodSeconds: &grace, + SecurityContext: &corev1.PodSecurityContext{}, + EnableServiceLinks: &enableServiceLinks, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "test", ResourceVersion: "11"}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + DNSPolicy: corev1.DNSClusterFirst, + TerminationGracePeriodSeconds: &grace, + SecurityContext: &corev1.PodSecurityContext{}, + EnableServiceLinks: &enableServiceLinks, + }, + }, + }, + } + svc := &corev1.ServiceList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "16", + }, + Items: []corev1.Service{ + { + ObjectMeta: metav1.ObjectMeta{Name: "baz", Namespace: "test", ResourceVersion: "12"}, + Spec: corev1.ServiceSpec{ + SessionAffinity: "None", + Type: corev1.ServiceTypeClusterIP, + }, + }, + }, + } + + one := int32(1) + rc := &corev1.ReplicationControllerList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "17", + }, + Items: []corev1.ReplicationController{ + { + ObjectMeta: metav1.ObjectMeta{Name: "rc1", Namespace: "test", ResourceVersion: "18"}, + Spec: corev1.ReplicationControllerSpec{ + Replicas: &one, + }, + }, + }, + } + return pods, svc, rc +} + +// EmptyTestData returns no pod, service, or replication controller +func EmptyTestData() (*corev1.PodList, *corev1.ServiceList, *corev1.ReplicationControllerList) { + pods := &corev1.PodList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "15", + }, + Items: []corev1.Pod{}, + } + svc := &corev1.ServiceList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "16", + }, + Items: []corev1.Service{}, + } + + rc := &corev1.ReplicationControllerList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "17", + }, + Items: []corev1.ReplicationController{}, + } + return pods, svc, rc +} + +func GenResponseWithJsonEncodedBody(bodyStruct interface{}) (*http.Response, error) { + jsonBytes, err := json.Marshal(bodyStruct) + if err != nil { + return nil, err + } + return &http.Response{StatusCode: 200, Header: DefaultHeader(), Body: BytesBody(jsonBytes)}, nil +} + +func InitTestErrorHandler(t *testing.T) { + cmdutil.BehaviorOnFatal(func(str string, code int) { + t.Errorf("Error running command (exit code %d): %s", code, str) + }) +} diff --git a/pkg/cmd/testing/zz_generated.deepcopy.go b/pkg/cmd/testing/zz_generated.deepcopy.go new file mode 100644 index 000000000..4c20a5aa6 --- /dev/null +++ b/pkg/cmd/testing/zz_generated.deepcopy.go @@ -0,0 +1,169 @@ +// +build !ignore_autogenerated + +/* +Copyright 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. +*/ + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package testing + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalNamespacedType) DeepCopyInto(out *ExternalNamespacedType) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalNamespacedType. +func (in *ExternalNamespacedType) DeepCopy() *ExternalNamespacedType { + if in == nil { + return nil + } + out := new(ExternalNamespacedType) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ExternalNamespacedType) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalNamespacedType2) DeepCopyInto(out *ExternalNamespacedType2) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalNamespacedType2. +func (in *ExternalNamespacedType2) DeepCopy() *ExternalNamespacedType2 { + if in == nil { + return nil + } + out := new(ExternalNamespacedType2) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ExternalNamespacedType2) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalType) DeepCopyInto(out *ExternalType) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalType. +func (in *ExternalType) DeepCopy() *ExternalType { + if in == nil { + return nil + } + out := new(ExternalType) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ExternalType) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalType2) DeepCopyInto(out *ExternalType2) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalType2. +func (in *ExternalType2) DeepCopy() *ExternalType2 { + if in == nil { + return nil + } + out := new(ExternalType2) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ExternalType2) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InternalNamespacedType) DeepCopyInto(out *InternalNamespacedType) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InternalNamespacedType. +func (in *InternalNamespacedType) DeepCopy() *InternalNamespacedType { + if in == nil { + return nil + } + out := new(InternalNamespacedType) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *InternalNamespacedType) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InternalType) DeepCopyInto(out *InternalType) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InternalType. +func (in *InternalType) DeepCopy() *InternalType { + if in == nil { + return nil + } + out := new(InternalType) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *InternalType) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/pkg/cmd/top/top.go b/pkg/cmd/top/top.go new file mode 100644 index 000000000..bc1458f88 --- /dev/null +++ b/pkg/cmd/top/top.go @@ -0,0 +1,76 @@ +/* +Copyright 2016 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 top + +import ( + "github.com/spf13/cobra" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + metricsapi "k8s.io/metrics/pkg/apis/metrics" +) + +const ( + sortByCPU = "cpu" + sortByMemory = "memory" +) + +var ( + supportedMetricsAPIVersions = []string{ + "v1beta1", + } + topLong = templates.LongDesc(i18n.T(` + Display Resource (CPU/Memory/Storage) usage. + + The top command allows you to see the resource consumption for nodes or pods. + + This command requires Heapster to be correctly configured and working on the server. `)) +) + +func NewCmdTop(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "top", + Short: i18n.T("Display Resource (CPU/Memory/Storage) usage."), + Long: topLong, + Run: cmdutil.DefaultSubCommandRun(streams.ErrOut), + } + + // create subcommands + cmd.AddCommand(NewCmdTopNode(f, nil, streams)) + cmd.AddCommand(NewCmdTopPod(f, nil, streams)) + + return cmd +} + +func SupportedMetricsAPIVersionAvailable(discoveredAPIGroups *metav1.APIGroupList) bool { + for _, discoveredAPIGroup := range discoveredAPIGroups.Groups { + if discoveredAPIGroup.Name != metricsapi.GroupName { + continue + } + for _, version := range discoveredAPIGroup.Versions { + for _, supportedVersion := range supportedMetricsAPIVersions { + if version.Version == supportedVersion { + return true + } + } + } + } + return false +} diff --git a/pkg/cmd/top/top_node.go b/pkg/cmd/top/top_node.go new file mode 100644 index 000000000..b13ea7513 --- /dev/null +++ b/pkg/cmd/top/top_node.go @@ -0,0 +1,249 @@ +/* +Copyright 2016 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 top + +import ( + "errors" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/discovery" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/metricsutil" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + metricsapi "k8s.io/metrics/pkg/apis/metrics" + metricsV1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1" + metricsclientset "k8s.io/metrics/pkg/client/clientset/versioned" +) + +// TopNodeOptions contains all the options for running the top-node cli command. +type TopNodeOptions struct { + ResourceName string + Selector string + SortBy string + NoHeaders bool + NodeClient corev1client.CoreV1Interface + HeapsterOptions HeapsterTopOptions + Client *metricsutil.HeapsterMetricsClient + Printer *metricsutil.TopCmdPrinter + DiscoveryClient discovery.DiscoveryInterface + MetricsClient metricsclientset.Interface + + genericclioptions.IOStreams +} + +type HeapsterTopOptions struct { + Namespace string + Service string + Scheme string + Port string +} + +func (o *HeapsterTopOptions) Bind(flags *pflag.FlagSet) { + if len(o.Namespace) == 0 { + o.Namespace = metricsutil.DefaultHeapsterNamespace + } + if len(o.Service) == 0 { + o.Service = metricsutil.DefaultHeapsterService + } + if len(o.Scheme) == 0 { + o.Scheme = metricsutil.DefaultHeapsterScheme + } + if len(o.Port) == 0 { + o.Port = metricsutil.DefaultHeapsterPort + } + + flags.StringVar(&o.Namespace, "heapster-namespace", o.Namespace, "Namespace Heapster service is located in") + flags.StringVar(&o.Service, "heapster-service", o.Service, "Name of Heapster service") + flags.StringVar(&o.Scheme, "heapster-scheme", o.Scheme, "Scheme (http or https) to connect to Heapster as") + flags.StringVar(&o.Port, "heapster-port", o.Port, "Port name in service to use") +} + +var ( + topNodeLong = templates.LongDesc(i18n.T(` + Display Resource (CPU/Memory/Storage) usage of nodes. + + The top-node command allows you to see the resource consumption of nodes.`)) + + topNodeExample = templates.Examples(i18n.T(` + # Show metrics for all nodes + kubectl top node + + # Show metrics for a given node + kubectl top node NODE_NAME`)) +) + +func NewCmdTopNode(f cmdutil.Factory, o *TopNodeOptions, streams genericclioptions.IOStreams) *cobra.Command { + if o == nil { + o = &TopNodeOptions{ + IOStreams: streams, + } + } + + cmd := &cobra.Command{ + Use: "node [NAME | -l label]", + DisableFlagsInUseLine: true, + Short: i18n.T("Display Resource (CPU/Memory/Storage) usage of nodes"), + Long: topNodeLong, + Example: topNodeExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunTopNode()) + }, + Aliases: []string{"nodes", "no"}, + } + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().StringVar(&o.SortBy, "sort-by", o.Selector, "If non-empty, sort nodes list using specified field. The field can be either 'cpu' or 'memory'.") + cmd.Flags().BoolVar(&o.NoHeaders, "no-headers", o.NoHeaders, "If present, print output without headers") + + o.HeapsterOptions.Bind(cmd.Flags()) + return cmd +} + +func (o *TopNodeOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + if len(args) == 1 { + o.ResourceName = args[0] + } else if len(args) > 1 { + return cmdutil.UsageErrorf(cmd, "%s", cmd.Use) + } + + clientset, err := f.KubernetesClientSet() + if err != nil { + return err + } + + o.DiscoveryClient = clientset.DiscoveryClient + + config, err := f.ToRESTConfig() + if err != nil { + return err + } + o.MetricsClient, err = metricsclientset.NewForConfig(config) + if err != nil { + return err + } + + o.NodeClient = clientset.CoreV1() + o.Client = metricsutil.NewHeapsterMetricsClient(clientset.CoreV1(), o.HeapsterOptions.Namespace, o.HeapsterOptions.Scheme, o.HeapsterOptions.Service, o.HeapsterOptions.Port) + + o.Printer = metricsutil.NewTopCmdPrinter(o.Out) + return nil +} + +func (o *TopNodeOptions) Validate() error { + if len(o.SortBy) > 0 { + if o.SortBy != sortByCPU && o.SortBy != sortByMemory { + return errors.New("--sort-by accepts only cpu or memory") + } + } + if len(o.ResourceName) > 0 && len(o.Selector) > 0 { + return errors.New("only one of NAME or --selector can be provided") + } + return nil +} + +func (o TopNodeOptions) RunTopNode() error { + var err error + selector := labels.Everything() + if len(o.Selector) > 0 { + selector, err = labels.Parse(o.Selector) + if err != nil { + return err + } + } + + apiGroups, err := o.DiscoveryClient.ServerGroups() + if err != nil { + return err + } + + metricsAPIAvailable := SupportedMetricsAPIVersionAvailable(apiGroups) + + metrics := &metricsapi.NodeMetricsList{} + if metricsAPIAvailable { + metrics, err = getNodeMetricsFromMetricsAPI(o.MetricsClient, o.ResourceName, selector) + if err != nil { + return err + } + } else { + metrics, err = o.Client.GetNodeMetrics(o.ResourceName, selector.String()) + if err != nil { + return err + } + } + + if len(metrics.Items) == 0 { + return errors.New("metrics not available yet") + } + + var nodes []v1.Node + if len(o.ResourceName) > 0 { + node, err := o.NodeClient.Nodes().Get(o.ResourceName, metav1.GetOptions{}) + if err != nil { + return err + } + nodes = append(nodes, *node) + } else { + nodeList, err := o.NodeClient.Nodes().List(metav1.ListOptions{ + LabelSelector: selector.String(), + }) + if err != nil { + return err + } + nodes = append(nodes, nodeList.Items...) + } + + allocatable := make(map[string]v1.ResourceList) + + for _, n := range nodes { + allocatable[n.Name] = n.Status.Allocatable + } + + return o.Printer.PrintNodeMetrics(metrics.Items, allocatable, o.NoHeaders, o.SortBy) +} + +func getNodeMetricsFromMetricsAPI(metricsClient metricsclientset.Interface, resourceName string, selector labels.Selector) (*metricsapi.NodeMetricsList, error) { + var err error + versionedMetrics := &metricsV1beta1api.NodeMetricsList{} + mc := metricsClient.MetricsV1beta1() + nm := mc.NodeMetricses() + if resourceName != "" { + m, err := nm.Get(resourceName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + versionedMetrics.Items = []metricsV1beta1api.NodeMetrics{*m} + } else { + versionedMetrics, err = nm.List(metav1.ListOptions{LabelSelector: selector.String()}) + if err != nil { + return nil, err + } + } + metrics := &metricsapi.NodeMetricsList{} + err = metricsV1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, metrics, nil) + if err != nil { + return nil, err + } + return metrics, nil +} diff --git a/pkg/cmd/top/top_node_test.go b/pkg/cmd/top/top_node_test.go new file mode 100644 index 000000000..83ac08e6a --- /dev/null +++ b/pkg/cmd/top/top_node_test.go @@ -0,0 +1,496 @@ +/* +Copyright 2016 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 top + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "net/url" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + core "k8s.io/client-go/testing" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" + metricsv1alpha1api "k8s.io/metrics/pkg/apis/metrics/v1alpha1" + metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1" + metricsfake "k8s.io/metrics/pkg/client/clientset/versioned/fake" +) + +const ( + apiPrefix = "api" + apiVersion = "v1" +) + +func TestTopNodeAllMetrics(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + metrics, nodes := testNodeV1alpha1MetricsData() + expectedMetricsPath := fmt.Sprintf("%s/%s/nodes", baseMetricsAddress, metricsAPIVersion) + expectedNodePath := fmt.Sprintf("/%s/%s/nodes", apiPrefix, apiVersion) + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apibody)))}, nil + case p == "/apis": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apisbody)))}, nil + case p == expectedMetricsPath && m == "GET": + body, err := marshallBody(metrics) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + case p == expectedNodePath && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, nodes)}, nil + default: + t.Fatalf("unexpected request: %#v\nGot URL: %#v\nExpected path: %#v", req, req.URL, expectedMetricsPath) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdTopNode(tf, nil, streams) + cmd.Flags().Set("no-headers", "true") + cmd.Run(cmd, []string{}) + + // Check the presence of node names in the output. + result := buf.String() + for _, m := range metrics.Items { + if !strings.Contains(result, m.Name) { + t.Errorf("missing metrics for %s: \n%s", m.Name, result) + } + } + if strings.Contains(result, "MEMORY") { + t.Errorf("should not print headers with --no-headers option set:\n%s\n", result) + } +} + +func TestTopNodeAllMetricsCustomDefaults(t *testing.T) { + customBaseHeapsterServiceAddress := "/api/v1/namespaces/custom-namespace/services/https:custom-heapster-service:/proxy" + customBaseMetricsAddress := customBaseHeapsterServiceAddress + "/apis/metrics" + + cmdtesting.InitTestErrorHandler(t) + metrics, nodes := testNodeV1alpha1MetricsData() + expectedMetricsPath := fmt.Sprintf("%s/%s/nodes", customBaseMetricsAddress, metricsAPIVersion) + expectedNodePath := fmt.Sprintf("/%s/%s/nodes", apiPrefix, apiVersion) + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apibody)))}, nil + case p == "/apis": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apisbody)))}, nil + case p == expectedMetricsPath && m == "GET": + body, err := marshallBody(metrics) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + case p == expectedNodePath && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, nodes)}, nil + default: + t.Fatalf("unexpected request: %#v\nGot URL: %#v\nExpected path: %#v", req, req.URL, expectedMetricsPath) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + opts := &TopNodeOptions{ + HeapsterOptions: HeapsterTopOptions{ + Namespace: "custom-namespace", + Scheme: "https", + Service: "custom-heapster-service", + }, + IOStreams: streams, + } + cmd := NewCmdTopNode(tf, opts, streams) + cmd.Run(cmd, []string{}) + + // Check the presence of node names in the output. + result := buf.String() + for _, m := range metrics.Items { + if !strings.Contains(result, m.Name) { + t.Errorf("missing metrics for %s: \n%s", m.Name, result) + } + } +} + +func TestTopNodeWithNameMetrics(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + metrics, nodes := testNodeV1alpha1MetricsData() + expectedMetrics := metrics.Items[0] + expectedNode := nodes.Items[0] + nonExpectedMetrics := metricsv1alpha1api.NodeMetricsList{ + ListMeta: metrics.ListMeta, + Items: metrics.Items[1:], + } + expectedPath := fmt.Sprintf("%s/%s/nodes/%s", baseMetricsAddress, metricsAPIVersion, expectedMetrics.Name) + expectedNodePath := fmt.Sprintf("/%s/%s/nodes/%s", apiPrefix, apiVersion, expectedMetrics.Name) + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apibody)))}, nil + case p == "/apis": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apisbody)))}, nil + case p == expectedPath && m == "GET": + body, err := marshallBody(expectedMetrics) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + case p == expectedNodePath && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &expectedNode)}, nil + default: + t.Fatalf("unexpected request: %#v\nGot URL: %#v\nExpected path: %#v", req, req.URL, expectedPath) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdTopNode(tf, nil, streams) + cmd.Run(cmd, []string{expectedMetrics.Name}) + + // Check the presence of node names in the output. + result := buf.String() + if !strings.Contains(result, expectedMetrics.Name) { + t.Errorf("missing metrics for %s: \n%s", expectedMetrics.Name, result) + } + for _, m := range nonExpectedMetrics.Items { + if strings.Contains(result, m.Name) { + t.Errorf("unexpected metrics for %s: \n%s", m.Name, result) + } + } +} + +func TestTopNodeWithLabelSelectorMetrics(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + metrics, nodes := testNodeV1alpha1MetricsData() + expectedMetrics := metricsv1alpha1api.NodeMetricsList{ + ListMeta: metrics.ListMeta, + Items: metrics.Items[0:1], + } + expectedNodes := v1.NodeList{ + ListMeta: nodes.ListMeta, + Items: nodes.Items[0:1], + } + nonExpectedMetrics := metricsv1alpha1api.NodeMetricsList{ + ListMeta: metrics.ListMeta, + Items: metrics.Items[1:], + } + label := "key=value" + expectedPath := fmt.Sprintf("%s/%s/nodes", baseMetricsAddress, metricsAPIVersion) + expectedQuery := fmt.Sprintf("labelSelector=%s", url.QueryEscape(label)) + expectedNodePath := fmt.Sprintf("/%s/%s/nodes", apiPrefix, apiVersion) + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m, q := req.URL.Path, req.Method, req.URL.RawQuery; { + case p == "/api": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apibody)))}, nil + case p == "/apis": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apisbody)))}, nil + case p == expectedPath && m == "GET" && q == expectedQuery: + body, err := marshallBody(expectedMetrics) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + case p == expectedNodePath && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &expectedNodes)}, nil + default: + t.Fatalf("unexpected request: %#v\nGot URL: %#v\nExpected path: %#v", req, req.URL, expectedPath) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdTopNode(tf, nil, streams) + cmd.Flags().Set("selector", label) + cmd.Run(cmd, []string{}) + + // Check the presence of node names in the output. + result := buf.String() + for _, m := range expectedMetrics.Items { + if !strings.Contains(result, m.Name) { + t.Errorf("missing metrics for %s: \n%s", m.Name, result) + } + } + for _, m := range nonExpectedMetrics.Items { + if strings.Contains(result, m.Name) { + t.Errorf("unexpected metrics for %s: \n%s", m.Name, result) + } + } +} + +func TestTopNodeAllMetricsFromMetricsServer(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + expectedMetrics, nodes := testNodeV1beta1MetricsData() + expectedNodePath := fmt.Sprintf("/%s/%s/nodes", apiPrefix, apiVersion) + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apibody)))}, nil + case p == "/apis": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apisbodyWithMetrics)))}, nil + case p == expectedNodePath && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, nodes)}, nil + default: + t.Fatalf("unexpected request: %#v\nGot URL: %#v\n", req, req.URL) + return nil, nil + } + }), + } + fakemetricsClientset := &metricsfake.Clientset{} + fakemetricsClientset.AddReactor("list", "nodes", func(action core.Action) (handled bool, ret runtime.Object, err error) { + return true, expectedMetrics, nil + }) + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdTopNode(tf, nil, streams) + + // TODO in the long run, we want to test most of our commands like this. Wire the options struct with specific mocks + // TODO then check the particular Run functionality and harvest results from fake clients + cmdOptions := &TopNodeOptions{ + IOStreams: streams, + } + if err := cmdOptions.Complete(tf, cmd, []string{}); err != nil { + t.Fatal(err) + } + cmdOptions.MetricsClient = fakemetricsClientset + if err := cmdOptions.Validate(); err != nil { + t.Fatal(err) + } + if err := cmdOptions.RunTopNode(); err != nil { + t.Fatal(err) + } + + // Check the presence of node names in the output. + result := buf.String() + for _, m := range expectedMetrics.Items { + if !strings.Contains(result, m.Name) { + t.Errorf("missing metrics for %s: \n%s", m.Name, result) + } + } +} + +func TestTopNodeWithNameMetricsFromMetricsServer(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + metrics, nodes := testNodeV1beta1MetricsData() + expectedMetrics := metrics.Items[0] + expectedNode := nodes.Items[0] + nonExpectedMetrics := metricsv1beta1api.NodeMetricsList{ + ListMeta: metrics.ListMeta, + Items: metrics.Items[1:], + } + expectedNodePath := fmt.Sprintf("/%s/%s/nodes/%s", apiPrefix, apiVersion, expectedMetrics.Name) + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m := req.URL.Path, req.Method; { + case p == "/api": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apibody)))}, nil + case p == "/apis": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apisbodyWithMetrics)))}, nil + case p == expectedNodePath && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &expectedNode)}, nil + default: + t.Fatalf("unexpected request: %#v\nGot URL: %#v\n", req, req.URL) + return nil, nil + } + }), + } + fakemetricsClientset := &metricsfake.Clientset{} + fakemetricsClientset.AddReactor("get", "nodes", func(action core.Action) (handled bool, ret runtime.Object, err error) { + return true, &expectedMetrics, nil + }) + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdTopNode(tf, nil, streams) + + // TODO in the long run, we want to test most of our commands like this. Wire the options struct with specific mocks + // TODO then check the particular Run functionality and harvest results from fake clients + cmdOptions := &TopNodeOptions{ + IOStreams: streams, + } + if err := cmdOptions.Complete(tf, cmd, []string{expectedMetrics.Name}); err != nil { + t.Fatal(err) + } + cmdOptions.MetricsClient = fakemetricsClientset + if err := cmdOptions.Validate(); err != nil { + t.Fatal(err) + } + if err := cmdOptions.RunTopNode(); err != nil { + t.Fatal(err) + } + + // Check the presence of node names in the output. + result := buf.String() + if !strings.Contains(result, expectedMetrics.Name) { + t.Errorf("missing metrics for %s: \n%s", expectedMetrics.Name, result) + } + for _, m := range nonExpectedMetrics.Items { + if strings.Contains(result, m.Name) { + t.Errorf("unexpected metrics for %s: \n%s", m.Name, result) + } + } +} + +func TestTopNodeWithLabelSelectorMetricsFromMetricsServer(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + metrics, nodes := testNodeV1beta1MetricsData() + expectedMetrics := &metricsv1beta1api.NodeMetricsList{ + ListMeta: metrics.ListMeta, + Items: metrics.Items[0:1], + } + expectedNodes := v1.NodeList{ + ListMeta: nodes.ListMeta, + Items: nodes.Items[0:1], + } + nonExpectedMetrics := &metricsv1beta1api.NodeMetricsList{ + ListMeta: metrics.ListMeta, + Items: metrics.Items[1:], + } + label := "key=value" + expectedNodePath := fmt.Sprintf("/%s/%s/nodes", apiPrefix, apiVersion) + + tf := cmdtesting.NewTestFactory().WithNamespace("test") + defer tf.Cleanup() + + codec := scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m, _ := req.URL.Path, req.Method, req.URL.RawQuery; { + case p == "/api": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apibody)))}, nil + case p == "/apis": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apisbodyWithMetrics)))}, nil + case p == expectedNodePath && m == "GET": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: cmdtesting.ObjBody(codec, &expectedNodes)}, nil + default: + t.Fatalf("unexpected request: %#v\nGot URL: %#v\n", req, req.URL) + return nil, nil + } + }), + } + + fakemetricsClientset := &metricsfake.Clientset{} + fakemetricsClientset.AddReactor("list", "nodes", func(action core.Action) (handled bool, ret runtime.Object, err error) { + return true, expectedMetrics, nil + }) + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdTopNode(tf, nil, streams) + cmd.Flags().Set("selector", label) + + // TODO in the long run, we want to test most of our commands like this. Wire the options struct with specific mocks + // TODO then check the particular Run functionality and harvest results from fake clients + cmdOptions := &TopNodeOptions{ + IOStreams: streams, + } + if err := cmdOptions.Complete(tf, cmd, []string{}); err != nil { + t.Fatal(err) + } + cmdOptions.MetricsClient = fakemetricsClientset + if err := cmdOptions.Validate(); err != nil { + t.Fatal(err) + } + if err := cmdOptions.RunTopNode(); err != nil { + t.Fatal(err) + } + + // Check the presence of node names in the output. + result := buf.String() + for _, m := range expectedMetrics.Items { + if !strings.Contains(result, m.Name) { + t.Errorf("missing metrics for %s: \n%s", m.Name, result) + } + } + for _, m := range nonExpectedMetrics.Items { + if strings.Contains(result, m.Name) { + t.Errorf("unexpected metrics for %s: \n%s", m.Name, result) + } + } +} diff --git a/pkg/cmd/top/top_pod.go b/pkg/cmd/top/top_pod.go new file mode 100644 index 000000000..04ab569fb --- /dev/null +++ b/pkg/cmd/top/top_pod.go @@ -0,0 +1,272 @@ +/* +Copyright 2016 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 top + +import ( + "errors" + "fmt" + "time" + + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/discovery" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/metricsutil" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + metricsapi "k8s.io/metrics/pkg/apis/metrics" + metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1" + metricsclientset "k8s.io/metrics/pkg/client/clientset/versioned" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/klog" +) + +type TopPodOptions struct { + ResourceName string + Namespace string + Selector string + SortBy string + AllNamespaces bool + PrintContainers bool + NoHeaders bool + PodClient corev1client.PodsGetter + HeapsterOptions HeapsterTopOptions + Client *metricsutil.HeapsterMetricsClient + Printer *metricsutil.TopCmdPrinter + DiscoveryClient discovery.DiscoveryInterface + MetricsClient metricsclientset.Interface + + genericclioptions.IOStreams +} + +const metricsCreationDelay = 2 * time.Minute + +var ( + topPodLong = templates.LongDesc(i18n.T(` + Display Resource (CPU/Memory/Storage) usage of pods. + + The 'top pod' command allows you to see the resource consumption of pods. + + Due to the metrics pipeline delay, they may be unavailable for a few minutes + since pod creation.`)) + + topPodExample = templates.Examples(i18n.T(` + # Show metrics for all pods in the default namespace + kubectl top pod + + # Show metrics for all pods in the given namespace + kubectl top pod --namespace=NAMESPACE + + # Show metrics for a given pod and its containers + kubectl top pod POD_NAME --containers + + # Show metrics for the pods defined by label name=myLabel + kubectl top pod -l name=myLabel`)) +) + +func NewCmdTopPod(f cmdutil.Factory, o *TopPodOptions, streams genericclioptions.IOStreams) *cobra.Command { + if o == nil { + o = &TopPodOptions{ + IOStreams: streams, + } + } + + cmd := &cobra.Command{ + Use: "pod [NAME | -l label]", + DisableFlagsInUseLine: true, + Short: i18n.T("Display Resource (CPU/Memory/Storage) usage of pods"), + Long: topPodLong, + Example: topPodExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd, args)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.RunTopPod()) + }, + Aliases: []string{"pods", "po"}, + } + cmd.Flags().StringVarP(&o.Selector, "selector", "l", o.Selector, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)") + cmd.Flags().StringVar(&o.SortBy, "sort-by", o.Selector, "If non-empty, sort pods list using specified field. The field can be either 'cpu' or 'memory'.") + cmd.Flags().BoolVar(&o.PrintContainers, "containers", o.PrintContainers, "If present, print usage of containers within a pod.") + cmd.Flags().BoolVarP(&o.AllNamespaces, "all-namespaces", "A", o.AllNamespaces, "If present, list the requested object(s) across all namespaces. Namespace in current context is ignored even if specified with --namespace.") + cmd.Flags().BoolVar(&o.NoHeaders, "no-headers", o.NoHeaders, "If present, print output without headers.") + o.HeapsterOptions.Bind(cmd.Flags()) + return cmd +} + +func (o *TopPodOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + if len(args) == 1 { + o.ResourceName = args[0] + } else if len(args) > 1 { + return cmdutil.UsageErrorf(cmd, "%s", cmd.Use) + } + + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + clientset, err := f.KubernetesClientSet() + if err != nil { + return err + } + + o.DiscoveryClient = clientset.DiscoveryClient + config, err := f.ToRESTConfig() + if err != nil { + return err + } + o.MetricsClient, err = metricsclientset.NewForConfig(config) + if err != nil { + return err + } + + o.PodClient = clientset.CoreV1() + o.Client = metricsutil.NewHeapsterMetricsClient(clientset.CoreV1(), o.HeapsterOptions.Namespace, o.HeapsterOptions.Scheme, o.HeapsterOptions.Service, o.HeapsterOptions.Port) + + o.Printer = metricsutil.NewTopCmdPrinter(o.Out) + return nil +} + +func (o *TopPodOptions) Validate() error { + if len(o.SortBy) > 0 { + if o.SortBy != sortByCPU && o.SortBy != sortByMemory { + return errors.New("--sort-by accepts only cpu or memory") + } + } + if len(o.ResourceName) > 0 && len(o.Selector) > 0 { + return errors.New("only one of NAME or --selector can be provided") + } + return nil +} + +func (o TopPodOptions) RunTopPod() error { + var err error + selector := labels.Everything() + if len(o.Selector) > 0 { + selector, err = labels.Parse(o.Selector) + if err != nil { + return err + } + } + + apiGroups, err := o.DiscoveryClient.ServerGroups() + if err != nil { + return err + } + + metricsAPIAvailable := SupportedMetricsAPIVersionAvailable(apiGroups) + + metrics := &metricsapi.PodMetricsList{} + if metricsAPIAvailable { + metrics, err = getMetricsFromMetricsAPI(o.MetricsClient, o.Namespace, o.ResourceName, o.AllNamespaces, selector) + if err != nil { + return err + } + } else { + metrics, err = o.Client.GetPodMetrics(o.Namespace, o.ResourceName, o.AllNamespaces, selector) + if err != nil { + return err + } + } + + // TODO: Refactor this once Heapster becomes the API server. + // First we check why no metrics have been received. + if len(metrics.Items) == 0 { + // If the API server query is successful but all the pods are newly created, + // the metrics are probably not ready yet, so we return the error here in the first place. + e := verifyEmptyMetrics(o, selector) + if e != nil { + return e + } + } + if err != nil { + return err + } + + return o.Printer.PrintPodMetrics(metrics.Items, o.PrintContainers, o.AllNamespaces, o.NoHeaders, o.SortBy) +} + +func getMetricsFromMetricsAPI(metricsClient metricsclientset.Interface, namespace, resourceName string, allNamespaces bool, selector labels.Selector) (*metricsapi.PodMetricsList, error) { + var err error + ns := metav1.NamespaceAll + if !allNamespaces { + ns = namespace + } + versionedMetrics := &metricsv1beta1api.PodMetricsList{} + if resourceName != "" { + m, err := metricsClient.MetricsV1beta1().PodMetricses(ns).Get(resourceName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m} + } else { + versionedMetrics, err = metricsClient.MetricsV1beta1().PodMetricses(ns).List(metav1.ListOptions{LabelSelector: selector.String()}) + if err != nil { + return nil, err + } + } + metrics := &metricsapi.PodMetricsList{} + err = metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, metrics, nil) + if err != nil { + return nil, err + } + return metrics, nil +} + +func verifyEmptyMetrics(o TopPodOptions, selector labels.Selector) error { + if len(o.ResourceName) > 0 { + pod, err := o.PodClient.Pods(o.Namespace).Get(o.ResourceName, metav1.GetOptions{}) + if err != nil { + return err + } + if err := checkPodAge(pod); err != nil { + return err + } + } else { + pods, err := o.PodClient.Pods(o.Namespace).List(metav1.ListOptions{ + LabelSelector: selector.String(), + }) + if err != nil { + return err + } + if len(pods.Items) == 0 { + return nil + } + for _, pod := range pods.Items { + if err := checkPodAge(&pod); err != nil { + return err + } + } + } + return errors.New("metrics not available yet") +} + +func checkPodAge(pod *v1.Pod) error { + age := time.Since(pod.CreationTimestamp.Time) + if age > metricsCreationDelay { + message := fmt.Sprintf("Metrics not available for pod %s/%s, age: %s", pod.Namespace, pod.Name, age.String()) + klog.Warningf(message) + return errors.New(message) + } else { + klog.V(2).Infof("Metrics not yet available for pod %s/%s, age: %s", pod.Namespace, pod.Name, age.String()) + return nil + } +} diff --git a/pkg/cmd/top/top_pod_test.go b/pkg/cmd/top/top_pod_test.go new file mode 100644 index 000000000..e654d9556 --- /dev/null +++ b/pkg/cmd/top/top_pod_test.go @@ -0,0 +1,737 @@ +/* +Copyright 2016 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 top + +import ( + "bytes" + "io/ioutil" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/googleapis/gnostic/OpenAPIv2" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + apiversion "k8s.io/apimachinery/pkg/version" + "k8s.io/cli-runtime/pkg/genericclioptions" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/rest/fake" + core "k8s.io/client-go/testing" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" + metricsv1alpha1api "k8s.io/metrics/pkg/apis/metrics/v1alpha1" + metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1" + metricsfake "k8s.io/metrics/pkg/client/clientset/versioned/fake" +) + +const ( + topPathPrefix = baseMetricsAddress + "/" + metricsAPIVersion + topMetricsAPIPathPrefix = "/apis/metrics.k8s.io/v1beta1" + apibody = `{ + "kind": "APIVersions", + "versions": [ + "v1" + ], + "serverAddressByClientCIDRs": [ + { + "clientCIDR": "0.0.0.0/0", + "serverAddress": "10.0.2.15:8443" + } + ] +}` + // This is not the full output one would usually get, just a trimmed down version. + apisbody = `{ + "kind": "APIGroupList", + "apiVersion": "v1", + "groups": [{}] +}` + + apisbodyWithMetrics = `{ + "kind": "APIGroupList", + "apiVersion": "v1", + "groups": [ + { + "name":"metrics.k8s.io", + "versions":[ + { + "groupVersion":"metrics.k8s.io/v1beta1", + "version":"v1beta1" + } + ], + "preferredVersion":{ + "groupVersion":"metrics.k8s.io/v1beta1", + "version":"v1beta1" + }, + "serverAddressByClientCIDRs":null + } + ] +}` +) + +func TestTopPod(t *testing.T) { + testNS := "testns" + testCases := []struct { + name string + flags map[string]string + args []string + expectedPath string + expectedQuery string + namespaces []string + containers bool + listsNamespaces bool + }{ + { + name: "all namespaces", + flags: map[string]string{"all-namespaces": "true"}, + expectedPath: topPathPrefix + "/pods", + namespaces: []string{testNS, "secondtestns", "thirdtestns"}, + listsNamespaces: true, + }, + { + name: "all in namespace", + expectedPath: topPathPrefix + "/namespaces/" + testNS + "/pods", + namespaces: []string{testNS, testNS}, + }, + { + name: "pod with name", + args: []string{"pod1"}, + expectedPath: topPathPrefix + "/namespaces/" + testNS + "/pods/pod1", + namespaces: []string{testNS}, + }, + { + name: "pod with label selector", + flags: map[string]string{"selector": "key=value"}, + expectedPath: topPathPrefix + "/namespaces/" + testNS + "/pods", + expectedQuery: "labelSelector=" + url.QueryEscape("key=value"), + namespaces: []string{testNS, testNS}, + }, + { + name: "pod with container metrics", + flags: map[string]string{"containers": "true"}, + args: []string{"pod1"}, + expectedPath: topPathPrefix + "/namespaces/" + testNS + "/pods/pod1", + namespaces: []string{testNS}, + containers: true, + }, + { + name: "no-headers set", + flags: map[string]string{"containers": "true", "no-headers": "true"}, + args: []string{"pod1"}, + expectedPath: topPathPrefix + "/namespaces/" + testNS + "/pods/pod1", + namespaces: []string{testNS}, + containers: true, + }, + } + cmdtesting.InitTestErrorHandler(t) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Logf("Running test case: %s", testCase.name) + metricsList := testPodMetricsData() + var expectedMetrics []metricsv1alpha1api.PodMetrics + var expectedContainerNames, nonExpectedMetricsNames []string + for n, m := range metricsList { + if n < len(testCase.namespaces) { + m.Namespace = testCase.namespaces[n] + expectedMetrics = append(expectedMetrics, m) + for _, c := range m.Containers { + expectedContainerNames = append(expectedContainerNames, c.Name) + } + } else { + nonExpectedMetricsNames = append(nonExpectedMetricsNames, m.Name) + } + } + + var response interface{} + if len(expectedMetrics) == 1 { + response = expectedMetrics[0] + } else { + response = metricsv1alpha1api.PodMetricsList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "2", + }, + Items: expectedMetrics, + } + } + + tf := cmdtesting.NewTestFactory().WithNamespace(testNS) + defer tf.Cleanup() + + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m, q := req.URL.Path, req.Method, req.URL.RawQuery; { + case p == "/api": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apibody)))}, nil + case p == "/apis": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apisbody)))}, nil + case p == testCase.expectedPath && m == "GET" && (testCase.expectedQuery == "" || q == testCase.expectedQuery): + body, err := marshallBody(response) + if err != nil { + t.Errorf("%s: unexpected error: %v", testCase.name, err) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Fatalf("%s: unexpected request: %#v\nGot URL: %#v\nExpected path: %#v\nExpected query: %#v", + testCase.name, req, req.URL, testCase.expectedPath, testCase.expectedQuery) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdTopPod(tf, nil, streams) + for name, value := range testCase.flags { + cmd.Flags().Set(name, value) + } + cmd.Run(cmd, testCase.args) + + // Check the presence of pod names&namespaces/container names in the output. + result := buf.String() + if testCase.containers { + for _, containerName := range expectedContainerNames { + if !strings.Contains(result, containerName) { + t.Errorf("%s: missing metrics for container %s: \n%s", testCase.name, containerName, result) + } + } + } + for _, m := range expectedMetrics { + if !strings.Contains(result, m.Name) { + t.Errorf("%s: missing metrics for %s: \n%s", testCase.name, m.Name, result) + } + if testCase.listsNamespaces && !strings.Contains(result, m.Namespace) { + t.Errorf("%s: missing metrics for %s/%s: \n%s", testCase.name, m.Namespace, m.Name, result) + } + } + for _, name := range nonExpectedMetricsNames { + if strings.Contains(result, name) { + t.Errorf("%s: unexpected metrics for %s: \n%s", testCase.name, name, result) + } + } + if cmdutil.GetFlagBool(cmd, "no-headers") && strings.Contains(result, "MEMORY") { + t.Errorf("%s: unexpected headers with no-headers option set: \n%s", testCase.name, result) + } + }) + } +} + +func TestTopPodWithMetricsServer(t *testing.T) { + testNS := "testns" + testCases := []struct { + name string + namespace string + options *TopPodOptions + args []string + expectedPath string + expectedQuery string + namespaces []string + containers bool + listsNamespaces bool + }{ + { + name: "all namespaces", + options: &TopPodOptions{AllNamespaces: true}, + expectedPath: topMetricsAPIPathPrefix + "/pods", + namespaces: []string{testNS, "secondtestns", "thirdtestns"}, + listsNamespaces: true, + }, + { + name: "all in namespace", + expectedPath: topMetricsAPIPathPrefix + "/namespaces/" + testNS + "/pods", + namespaces: []string{testNS, testNS}, + }, + { + name: "pod with name", + args: []string{"pod1"}, + expectedPath: topMetricsAPIPathPrefix + "/namespaces/" + testNS + "/pods/pod1", + namespaces: []string{testNS}, + }, + { + name: "pod with label selector", + options: &TopPodOptions{Selector: "key=value"}, + expectedPath: topMetricsAPIPathPrefix + "/namespaces/" + testNS + "/pods", + expectedQuery: "labelSelector=" + url.QueryEscape("key=value"), + namespaces: []string{testNS, testNS}, + }, + { + name: "pod with container metrics", + options: &TopPodOptions{PrintContainers: true}, + args: []string{"pod1"}, + expectedPath: topMetricsAPIPathPrefix + "/namespaces/" + testNS + "/pods/pod1", + namespaces: []string{testNS}, + containers: true, + }, + } + cmdtesting.InitTestErrorHandler(t) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + metricsList := testV1beta1PodMetricsData() + var expectedMetrics []metricsv1beta1api.PodMetrics + var expectedContainerNames, nonExpectedMetricsNames []string + for n, m := range metricsList { + if n < len(testCase.namespaces) { + m.Namespace = testCase.namespaces[n] + expectedMetrics = append(expectedMetrics, m) + for _, c := range m.Containers { + expectedContainerNames = append(expectedContainerNames, c.Name) + } + } else { + nonExpectedMetricsNames = append(nonExpectedMetricsNames, m.Name) + } + } + + fakemetricsClientset := &metricsfake.Clientset{} + + if len(expectedMetrics) == 1 { + fakemetricsClientset.AddReactor("get", "pods", func(action core.Action) (handled bool, ret runtime.Object, err error) { + return true, &expectedMetrics[0], nil + }) + } else { + fakemetricsClientset.AddReactor("list", "pods", func(action core.Action) (handled bool, ret runtime.Object, err error) { + res := &metricsv1beta1api.PodMetricsList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "2", + }, + Items: expectedMetrics, + } + return true, res, nil + }) + } + + tf := cmdtesting.NewTestFactory().WithNamespace(testNS) + defer tf.Cleanup() + + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p := req.URL.Path; { + case p == "/api": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apibody)))}, nil + case p == "/apis": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apisbodyWithMetrics)))}, nil + default: + t.Fatalf("%s: unexpected request: %#v\nGot URL: %#v", + testCase.name, req, req.URL) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + cmd := NewCmdTopPod(tf, nil, streams) + var cmdOptions *TopPodOptions + if testCase.options != nil { + cmdOptions = testCase.options + } else { + cmdOptions = &TopPodOptions{} + } + cmdOptions.IOStreams = streams + + // TODO in the long run, we want to test most of our commands like this. Wire the options struct with specific mocks + // TODO then check the particular Run functionality and harvest results from fake clients. We probably end up skipping the factory altogether. + if err := cmdOptions.Complete(tf, cmd, testCase.args); err != nil { + t.Fatal(err) + } + cmdOptions.MetricsClient = fakemetricsClientset + if err := cmdOptions.Validate(); err != nil { + t.Fatal(err) + } + if err := cmdOptions.RunTopPod(); err != nil { + t.Fatal(err) + } + + // Check the presence of pod names&namespaces/container names in the output. + result := buf.String() + if testCase.containers { + for _, containerName := range expectedContainerNames { + if !strings.Contains(result, containerName) { + t.Errorf("missing metrics for container %s: \n%s", containerName, result) + } + } + } + for _, m := range expectedMetrics { + if !strings.Contains(result, m.Name) { + t.Errorf("missing metrics for %s: \n%s", m.Name, result) + } + if testCase.listsNamespaces && !strings.Contains(result, m.Namespace) { + t.Errorf("missing metrics for %s/%s: \n%s", m.Namespace, m.Name, result) + } + } + for _, name := range nonExpectedMetricsNames { + if strings.Contains(result, name) { + t.Errorf("unexpected metrics for %s: \n%s", name, result) + } + } + }) + } +} + +type fakeDiscovery struct{} + +// ServerGroups returns the supported groups, with information like supported versions and the +// preferred version. +func (d *fakeDiscovery) ServerGroups() (*metav1.APIGroupList, error) { + return nil, nil +} + +// ServerResourcesForGroupVersion returns the supported resources for a group and version. +func (d *fakeDiscovery) ServerResourcesForGroupVersion(groupVersion string) (*metav1.APIResourceList, error) { + return nil, nil +} + +// ServerResources returns the supported resources for all groups and versions. +// Deprecated: use ServerGroupsAndResources instead. +func (d *fakeDiscovery) ServerResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} + +// ServerGroupsAndResources returns the supported groups and resources for all groups and versions. +func (d *fakeDiscovery) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) { + return nil, nil, nil +} + +// ServerPreferredResources returns the supported resources with the version preferred by the +// server. +func (d *fakeDiscovery) ServerPreferredResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} + +// ServerPreferredNamespacedResources returns the supported namespaced resources with the +// version preferred by the server. +func (d *fakeDiscovery) ServerPreferredNamespacedResources() ([]*metav1.APIResourceList, error) { + return nil, nil +} + +// ServerVersion retrieves and parses the server's version (git version). +func (d *fakeDiscovery) ServerVersion() (*apiversion.Info, error) { + return nil, nil +} + +// OpenAPISchema retrieves and parses the swagger API schema the server supports. +func (d *fakeDiscovery) OpenAPISchema() (*openapi_v2.Document, error) { + return nil, nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (d *fakeDiscovery) RESTClient() restclient.Interface { + return nil +} + +func TestTopPodCustomDefaults(t *testing.T) { + customBaseHeapsterServiceAddress := "/api/v1/namespaces/custom-namespace/services/https:custom-heapster-service:/proxy" + customBaseMetricsAddress := customBaseHeapsterServiceAddress + "/apis/metrics" + customTopPathPrefix := customBaseMetricsAddress + "/" + metricsAPIVersion + + testNS := "custom-namespace" + testCases := []struct { + name string + flags map[string]string + args []string + expectedPath string + expectedQuery string + namespaces []string + containers bool + listsNamespaces bool + }{ + { + name: "all namespaces", + flags: map[string]string{"all-namespaces": "true"}, + expectedPath: customTopPathPrefix + "/pods", + namespaces: []string{testNS, "secondtestns", "thirdtestns"}, + listsNamespaces: true, + }, + { + name: "all in namespace", + expectedPath: customTopPathPrefix + "/namespaces/" + testNS + "/pods", + namespaces: []string{testNS, testNS}, + }, + { + name: "pod with name", + args: []string{"pod1"}, + expectedPath: customTopPathPrefix + "/namespaces/" + testNS + "/pods/pod1", + namespaces: []string{testNS}, + }, + { + name: "pod with label selector", + flags: map[string]string{"selector": "key=value"}, + expectedPath: customTopPathPrefix + "/namespaces/" + testNS + "/pods", + expectedQuery: "labelSelector=" + url.QueryEscape("key=value"), + namespaces: []string{testNS, testNS}, + }, + { + name: "pod with container metrics", + flags: map[string]string{"containers": "true"}, + args: []string{"pod1"}, + expectedPath: customTopPathPrefix + "/namespaces/" + testNS + "/pods/pod1", + namespaces: []string{testNS}, + containers: true, + }, + } + cmdtesting.InitTestErrorHandler(t) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Logf("Running test case: %s", testCase.name) + metricsList := testPodMetricsData() + var expectedMetrics []metricsv1alpha1api.PodMetrics + var expectedContainerNames, nonExpectedMetricsNames []string + for n, m := range metricsList { + if n < len(testCase.namespaces) { + m.Namespace = testCase.namespaces[n] + expectedMetrics = append(expectedMetrics, m) + for _, c := range m.Containers { + expectedContainerNames = append(expectedContainerNames, c.Name) + } + } else { + nonExpectedMetricsNames = append(nonExpectedMetricsNames, m.Name) + } + } + + var response interface{} + if len(expectedMetrics) == 1 { + response = expectedMetrics[0] + } else { + response = metricsv1alpha1api.PodMetricsList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "2", + }, + Items: expectedMetrics, + } + } + + tf := cmdtesting.NewTestFactory().WithNamespace(testNS) + defer tf.Cleanup() + + ns := scheme.Codecs + + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + switch p, m, q := req.URL.Path, req.Method, req.URL.RawQuery; { + case p == "/api": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apibody)))}, nil + case p == "/apis": + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: ioutil.NopCloser(bytes.NewReader([]byte(apisbody)))}, nil + case p == testCase.expectedPath && m == "GET" && (testCase.expectedQuery == "" || q == testCase.expectedQuery): + body, err := marshallBody(response) + if err != nil { + t.Errorf("%s: unexpected error: %v", testCase.name, err) + } + return &http.Response{StatusCode: 200, Header: cmdtesting.DefaultHeader(), Body: body}, nil + default: + t.Fatalf("%s: unexpected request: %#v\nGot URL: %#v\nExpected path: %#v\nExpected query: %#v", + testCase.name, req, req.URL, testCase.expectedPath, testCase.expectedQuery) + return nil, nil + } + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + streams, _, buf, _ := genericclioptions.NewTestIOStreams() + + opts := &TopPodOptions{ + HeapsterOptions: HeapsterTopOptions{ + Namespace: "custom-namespace", + Scheme: "https", + Service: "custom-heapster-service", + }, + DiscoveryClient: &fakeDiscovery{}, + IOStreams: streams, + } + cmd := NewCmdTopPod(tf, opts, streams) + for name, value := range testCase.flags { + cmd.Flags().Set(name, value) + } + cmd.Run(cmd, testCase.args) + + // Check the presence of pod names&namespaces/container names in the output. + result := buf.String() + if testCase.containers { + for _, containerName := range expectedContainerNames { + if !strings.Contains(result, containerName) { + t.Errorf("%s: missing metrics for container %s: \n%s", testCase.name, containerName, result) + } + } + } + for _, m := range expectedMetrics { + if !strings.Contains(result, m.Name) { + t.Errorf("%s: missing metrics for %s: \n%s", testCase.name, m.Name, result) + } + if testCase.listsNamespaces && !strings.Contains(result, m.Namespace) { + t.Errorf("%s: missing metrics for %s/%s: \n%s", testCase.name, m.Namespace, m.Name, result) + } + } + for _, name := range nonExpectedMetricsNames { + if strings.Contains(result, name) { + t.Errorf("%s: unexpected metrics for %s: \n%s", testCase.name, name, result) + } + } + }) + } +} + +func testV1beta1PodMetricsData() []metricsv1beta1api.PodMetrics { + return []metricsv1beta1api.PodMetrics{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pod1", Namespace: "test", ResourceVersion: "10", Labels: map[string]string{"key": "value"}}, + Window: metav1.Duration{Duration: time.Minute}, + Containers: []metricsv1beta1api.ContainerMetrics{ + { + Name: "container1-1", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(1, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(3*(1024*1024), resource.DecimalSI), + }, + }, + { + Name: "container1-2", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(4, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(5*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(6*(1024*1024), resource.DecimalSI), + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pod2", Namespace: "test", ResourceVersion: "11", Labels: map[string]string{"key": "value"}}, + Window: metav1.Duration{Duration: time.Minute}, + Containers: []metricsv1beta1api.ContainerMetrics{ + { + Name: "container2-1", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(7, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(8*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(9*(1024*1024), resource.DecimalSI), + }, + }, + { + Name: "container2-2", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(10, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(11*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(12*(1024*1024), resource.DecimalSI), + }, + }, + { + Name: "container2-3", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(13, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(14*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(15*(1024*1024), resource.DecimalSI), + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pod3", Namespace: "test", ResourceVersion: "12"}, + Window: metav1.Duration{Duration: time.Minute}, + Containers: []metricsv1beta1api.ContainerMetrics{ + { + Name: "container3-1", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(7, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(8*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(9*(1024*1024), resource.DecimalSI), + }, + }, + }, + }, + } +} + +func testPodMetricsData() []metricsv1alpha1api.PodMetrics { + return []metricsv1alpha1api.PodMetrics{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pod1", Namespace: "test", ResourceVersion: "10"}, + Window: metav1.Duration{Duration: time.Minute}, + Containers: []metricsv1alpha1api.ContainerMetrics{ + { + Name: "container1-1", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(1, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(3*(1024*1024), resource.DecimalSI), + }, + }, + { + Name: "container1-2", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(4, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(5*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(6*(1024*1024), resource.DecimalSI), + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pod2", Namespace: "test", ResourceVersion: "11"}, + Window: metav1.Duration{Duration: time.Minute}, + Containers: []metricsv1alpha1api.ContainerMetrics{ + { + Name: "container2-1", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(7, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(8*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(9*(1024*1024), resource.DecimalSI), + }, + }, + { + Name: "container2-2", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(10, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(11*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(12*(1024*1024), resource.DecimalSI), + }, + }, + { + Name: "container2-3", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(13, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(14*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(15*(1024*1024), resource.DecimalSI), + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pod3", Namespace: "test", ResourceVersion: "12"}, + Window: metav1.Duration{Duration: time.Minute}, + Containers: []metricsv1alpha1api.ContainerMetrics{ + { + Name: "container3-1", + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(7, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(8*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(9*(1024*1024), resource.DecimalSI), + }, + }, + }, + }, + } +} diff --git a/pkg/cmd/top/top_test.go b/pkg/cmd/top/top_test.go new file mode 100644 index 000000000..eaa685644 --- /dev/null +++ b/pkg/cmd/top/top_test.go @@ -0,0 +1,174 @@ +/* +Copyright 2016 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 top + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "time" + + "testing" + + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing" + metricsv1alpha1api "k8s.io/metrics/pkg/apis/metrics/v1alpha1" + metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1" +) + +const ( + baseHeapsterServiceAddress = "/api/v1/namespaces/kube-system/services/http:heapster:/proxy" + baseMetricsAddress = baseHeapsterServiceAddress + "/apis/metrics" + baseMetricsServerAddress = "/apis/metrics.k8s.io/v1beta1" + metricsAPIVersion = "v1alpha1" +) + +func TestTopSubcommandsExist(t *testing.T) { + cmdtesting.InitTestErrorHandler(t) + + f := cmdtesting.NewTestFactory() + defer f.Cleanup() + + cmd := NewCmdTop(f, genericclioptions.NewTestIOStreamsDiscard()) + if !cmd.HasSubCommands() { + t.Error("top command should have subcommands") + } +} + +func marshallBody(metrics interface{}) (io.ReadCloser, error) { + result, err := json.Marshal(metrics) + if err != nil { + return nil, err + } + return ioutil.NopCloser(bytes.NewReader(result)), nil +} + +func testNodeV1alpha1MetricsData() (*metricsv1alpha1api.NodeMetricsList, *v1.NodeList) { + metrics := &metricsv1alpha1api.NodeMetricsList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "1", + }, + Items: []metricsv1alpha1api.NodeMetrics{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node1", ResourceVersion: "10"}, + Window: metav1.Duration{Duration: time.Minute}, + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(1, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(3*(1024*1024), resource.DecimalSI), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node2", ResourceVersion: "11"}, + Window: metav1.Duration{Duration: time.Minute}, + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(5, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(6*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(7*(1024*1024), resource.DecimalSI), + }, + }, + }, + } + nodes := &v1.NodeList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "15", + }, + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node1", ResourceVersion: "10"}, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(10, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(20*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(30*(1024*1024), resource.DecimalSI), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node2", ResourceVersion: "11"}, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(50, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(60*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(70*(1024*1024), resource.DecimalSI), + }, + }, + }, + }, + } + return metrics, nodes +} + +func testNodeV1beta1MetricsData() (*metricsv1beta1api.NodeMetricsList, *v1.NodeList) { + metrics := &metricsv1beta1api.NodeMetricsList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "1", + }, + Items: []metricsv1beta1api.NodeMetrics{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node1", ResourceVersion: "10", Labels: map[string]string{"key": "value"}}, + Window: metav1.Duration{Duration: time.Minute}, + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(1, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(2*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(3*(1024*1024), resource.DecimalSI), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node2", ResourceVersion: "11"}, + Window: metav1.Duration{Duration: time.Minute}, + Usage: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(5, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(6*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(7*(1024*1024), resource.DecimalSI), + }, + }, + }, + } + nodes := &v1.NodeList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "15", + }, + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node1", ResourceVersion: "10"}, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(10, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(20*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(30*(1024*1024), resource.DecimalSI), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node2", ResourceVersion: "11"}, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(50, resource.DecimalSI), + v1.ResourceMemory: *resource.NewQuantity(60*(1024*1024), resource.DecimalSI), + v1.ResourceStorage: *resource.NewQuantity(70*(1024*1024), resource.DecimalSI), + }, + }, + }, + }, + } + return metrics, nodes +} diff --git a/pkg/cmd/version/version.go b/pkg/cmd/version/version.go new file mode 100644 index 000000000..a5659bc61 --- /dev/null +++ b/pkg/cmd/version/version.go @@ -0,0 +1,162 @@ +/* +Copyright 2014 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 version + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + apimachineryversion "k8s.io/apimachinery/pkg/version" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/discovery" + "k8s.io/client-go/tools/clientcmd" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/i18n" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubectl/pkg/version" +) + +// Version is a struct for version information +type Version struct { + ClientVersion *apimachineryversion.Info `json:"clientVersion,omitempty" yaml:"clientVersion,omitempty"` + ServerVersion *apimachineryversion.Info `json:"serverVersion,omitempty" yaml:"serverVersion,omitempty"` +} + +var ( + versionExample = templates.Examples(i18n.T(` + # Print the client and server versions for the current context + kubectl version`)) +) + +// Options is a struct to support version command +type Options struct { + ClientOnly bool + Short bool + Output string + + discoveryClient discovery.CachedDiscoveryInterface + + genericclioptions.IOStreams +} + +// NewOptions returns initialized Options +func NewOptions(ioStreams genericclioptions.IOStreams) *Options { + return &Options{ + IOStreams: ioStreams, + } + +} + +// NewCmdVersion returns a cobra command for fetching versions +func NewCmdVersion(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewOptions(ioStreams) + cmd := &cobra.Command{ + Use: "version", + Short: i18n.T("Print the client and server version information"), + Long: "Print the client and server version information for the current context", + Example: versionExample, + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.Complete(f, cmd)) + cmdutil.CheckErr(o.Validate()) + cmdutil.CheckErr(o.Run()) + }, + } + cmd.Flags().BoolVar(&o.ClientOnly, "client", o.ClientOnly, "Client version only (no server required).") + cmd.Flags().BoolVar(&o.Short, "short", o.Short, "Print just the version number.") + cmd.Flags().StringVarP(&o.Output, "output", "o", o.Output, "One of 'yaml' or 'json'.") + return cmd +} + +// Complete completes all the required options +func (o *Options) Complete(f cmdutil.Factory, cmd *cobra.Command) error { + var err error + if o.ClientOnly { + return nil + } + o.discoveryClient, err = f.ToDiscoveryClient() + // if we had an empty rest.Config, continue and just print out client information. + // if we had an error other than being unable to build a rest.Config, fail. + if err != nil && !clientcmd.IsEmptyConfig(err) { + return err + } + return nil +} + +// Validate validates the provided options +func (o *Options) Validate() error { + if o.Output != "" && o.Output != "yaml" && o.Output != "json" { + return errors.New(`--output must be 'yaml' or 'json'`) + } + + return nil +} + +// Run executes version command +func (o *Options) Run() error { + var ( + serverVersion *apimachineryversion.Info + serverErr error + versionInfo Version + ) + + clientVersion := version.Get() + versionInfo.ClientVersion = &clientVersion + + if !o.ClientOnly && o.discoveryClient != nil { + // Always request fresh data from the server + o.discoveryClient.Invalidate() + serverVersion, serverErr = o.discoveryClient.ServerVersion() + versionInfo.ServerVersion = serverVersion + } + + switch o.Output { + case "": + if o.Short { + fmt.Fprintf(o.Out, "Client Version: %s\n", clientVersion.GitVersion) + if serverVersion != nil { + fmt.Fprintf(o.Out, "Server Version: %s\n", serverVersion.GitVersion) + } + } else { + fmt.Fprintf(o.Out, "Client Version: %s\n", fmt.Sprintf("%#v", clientVersion)) + if serverVersion != nil { + fmt.Fprintf(o.Out, "Server Version: %s\n", fmt.Sprintf("%#v", *serverVersion)) + } + } + case "yaml": + marshalled, err := yaml.Marshal(&versionInfo) + if err != nil { + return err + } + fmt.Fprintln(o.Out, string(marshalled)) + case "json": + marshalled, err := json.MarshalIndent(&versionInfo, "", " ") + if err != nil { + return err + } + fmt.Fprintln(o.Out, string(marshalled)) + default: + // There is a bug in the program if we hit this case. + // However, we follow a policy of never panicking. + return fmt.Errorf("VersionOptions were not validated: --output=%q should have been rejected", o.Output) + } + + return serverErr +} diff --git a/pkg/cmd/wait/wait.go b/pkg/cmd/wait/wait.go new file mode 100644 index 000000000..6cef7907a --- /dev/null +++ b/pkg/cmd/wait/wait.go @@ -0,0 +1,460 @@ +/* +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 wait + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/dynamic" + watchtools "k8s.io/client-go/tools/watch" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/util/templates" +) + +var ( + waitLong = templates.LongDesc(` + Experimental: Wait for a specific condition on one or many resources. + + The command takes multiple resources and waits until the specified condition + is seen in the Status field of every given resource. + + Alternatively, the command can wait for the given set of resources to be deleted + by providing the "delete" keyword as the value to the --for flag. + + A successful message will be printed to stdout indicating when the specified + condition has been met. One can use -o option to change to output destination.`) + + waitExample = templates.Examples(` + # Wait for the pod "busybox1" to contain the status condition of type "Ready". + kubectl wait --for=condition=Ready pod/busybox1 + + # Wait for the pod "busybox1" to be deleted, with a timeout of 60s, after having issued the "delete" command. + kubectl delete pod/busybox1 + kubectl wait --for=delete pod/busybox1 --timeout=60s`) +) + +// errNoMatchingResources is returned when there is no resources matching a query. +var errNoMatchingResources = errors.New("no matching resources found") + +// WaitFlags directly reflect the information that CLI is gathering via flags. They will be converted to Options, which +// reflect the runtime requirements for the command. This structure reduces the transformation to wiring and makes +// the logic itself easy to unit test +type WaitFlags struct { + RESTClientGetter genericclioptions.RESTClientGetter + PrintFlags *genericclioptions.PrintFlags + ResourceBuilderFlags *genericclioptions.ResourceBuilderFlags + + Timeout time.Duration + ForCondition string + + genericclioptions.IOStreams +} + +// NewWaitFlags returns a default WaitFlags +func NewWaitFlags(restClientGetter genericclioptions.RESTClientGetter, streams genericclioptions.IOStreams) *WaitFlags { + return &WaitFlags{ + RESTClientGetter: restClientGetter, + PrintFlags: genericclioptions.NewPrintFlags("condition met"), + ResourceBuilderFlags: genericclioptions.NewResourceBuilderFlags(). + WithLabelSelector(""). + WithFieldSelector(""). + WithAll(false). + WithAllNamespaces(false). + WithLocal(false). + WithLatest(), + + Timeout: 30 * time.Second, + + IOStreams: streams, + } +} + +// NewCmdWait returns a cobra command for waiting +func NewCmdWait(restClientGetter genericclioptions.RESTClientGetter, streams genericclioptions.IOStreams) *cobra.Command { + flags := NewWaitFlags(restClientGetter, streams) + + cmd := &cobra.Command{ + Use: "wait ([-f FILENAME] | resource.group/resource.name | resource.group [(-l label | --all)]) [--for=delete|--for condition=available]", + Short: "Experimental: Wait for a specific condition on one or many resources.", + Long: waitLong, + Example: waitExample, + + DisableFlagsInUseLine: true, + Run: func(cmd *cobra.Command, args []string) { + o, err := flags.ToOptions(args) + cmdutil.CheckErr(err) + err = o.RunWait() + cmdutil.CheckErr(err) + }, + SuggestFor: []string{"list", "ps"}, + } + + flags.AddFlags(cmd) + + return cmd +} + +// AddFlags registers flags for a cli +func (flags *WaitFlags) AddFlags(cmd *cobra.Command) { + flags.PrintFlags.AddFlags(cmd) + flags.ResourceBuilderFlags.AddFlags(cmd.Flags()) + + cmd.Flags().DurationVar(&flags.Timeout, "timeout", flags.Timeout, "The length of time to wait before giving up. Zero means check once and don't wait, negative means wait for a week.") + cmd.Flags().StringVar(&flags.ForCondition, "for", flags.ForCondition, "The condition to wait on: [delete|condition=condition-name].") +} + +// ToOptions converts from CLI inputs to runtime inputs +func (flags *WaitFlags) ToOptions(args []string) (*WaitOptions, error) { + printer, err := flags.PrintFlags.ToPrinter() + if err != nil { + return nil, err + } + builder := flags.ResourceBuilderFlags.ToBuilder(flags.RESTClientGetter, args) + clientConfig, err := flags.RESTClientGetter.ToRESTConfig() + if err != nil { + return nil, err + } + dynamicClient, err := dynamic.NewForConfig(clientConfig) + if err != nil { + return nil, err + } + conditionFn, err := conditionFuncFor(flags.ForCondition, flags.ErrOut) + if err != nil { + return nil, err + } + + effectiveTimeout := flags.Timeout + if effectiveTimeout < 0 { + effectiveTimeout = 168 * time.Hour + } + + o := &WaitOptions{ + ResourceFinder: builder, + DynamicClient: dynamicClient, + Timeout: effectiveTimeout, + + Printer: printer, + ConditionFn: conditionFn, + IOStreams: flags.IOStreams, + } + + return o, nil +} + +func conditionFuncFor(condition string, errOut io.Writer) (ConditionFunc, error) { + if strings.ToLower(condition) == "delete" { + return IsDeleted, nil + } + if strings.HasPrefix(condition, "condition=") { + conditionName := condition[len("condition="):] + conditionValue := "true" + if equalsIndex := strings.Index(conditionName, "="); equalsIndex != -1 { + conditionValue = conditionName[equalsIndex+1:] + conditionName = conditionName[0:equalsIndex] + } + + return ConditionalWait{ + conditionName: conditionName, + conditionStatus: conditionValue, + errOut: errOut, + }.IsConditionMet, nil + } + + return nil, fmt.Errorf("unrecognized condition: %q", condition) +} + +// ResourceLocation holds the location of a resource +type ResourceLocation struct { + GroupResource schema.GroupResource + Namespace string + Name string +} + +// UIDMap maps ResourceLocation with UID +type UIDMap map[ResourceLocation]types.UID + +// WaitOptions is a set of options that allows you to wait. This is the object reflects the runtime needs of a wait +// command, making the logic itself easy to unit test with our existing mocks. +type WaitOptions struct { + ResourceFinder genericclioptions.ResourceFinder + // UIDMap maps a resource location to a UID. It is optional, but ConditionFuncs may choose to use it to make the result + // more reliable. For instance, delete can look for UID consistency during delegated calls. + UIDMap UIDMap + DynamicClient dynamic.Interface + Timeout time.Duration + + Printer printers.ResourcePrinter + ConditionFn ConditionFunc + genericclioptions.IOStreams +} + +// ConditionFunc is the interface for providing condition checks +type ConditionFunc func(info *resource.Info, o *WaitOptions) (finalObject runtime.Object, done bool, err error) + +// RunWait runs the waiting logic +func (o *WaitOptions) RunWait() error { + visitCount := 0 + err := o.ResourceFinder.Do().Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + visitCount++ + finalObject, success, err := o.ConditionFn(info, o) + if success { + o.Printer.PrintObj(finalObject, o.Out) + return nil + } + if err == nil { + return fmt.Errorf("%v unsatisified for unknown reason", finalObject) + } + return err + }) + if err != nil { + return err + } + if visitCount == 0 { + return errNoMatchingResources + } + return err +} + +// IsDeleted is a condition func for waiting for something to be deleted +func IsDeleted(info *resource.Info, o *WaitOptions) (runtime.Object, bool, error) { + endTime := time.Now().Add(o.Timeout) + for { + if len(info.Name) == 0 { + return info.Object, false, fmt.Errorf("resource name must be provided") + } + + nameSelector := fields.OneTermEqualSelector("metadata.name", info.Name).String() + + // List with a name field selector to get the current resourceVersion to watch from (not the object's resourceVersion) + gottenObjList, err := o.DynamicClient.Resource(info.Mapping.Resource).Namespace(info.Namespace).List(metav1.ListOptions{FieldSelector: nameSelector}) + if apierrors.IsNotFound(err) { + return info.Object, true, nil + } + if err != nil { + // TODO this could do something slightly fancier if we wish + return info.Object, false, err + } + if len(gottenObjList.Items) != 1 { + return info.Object, true, nil + } + gottenObj := &gottenObjList.Items[0] + resourceLocation := ResourceLocation{ + GroupResource: info.Mapping.Resource.GroupResource(), + Namespace: gottenObj.GetNamespace(), + Name: gottenObj.GetName(), + } + if uid, ok := o.UIDMap[resourceLocation]; ok { + if gottenObj.GetUID() != uid { + return gottenObj, true, nil + } + } + + watchOptions := metav1.ListOptions{} + watchOptions.FieldSelector = nameSelector + watchOptions.ResourceVersion = gottenObjList.GetResourceVersion() + objWatch, err := o.DynamicClient.Resource(info.Mapping.Resource).Namespace(info.Namespace).Watch(watchOptions) + if err != nil { + return gottenObj, false, err + } + + timeout := endTime.Sub(time.Now()) + errWaitTimeoutWithName := extendErrWaitTimeout(wait.ErrWaitTimeout, info) + if timeout < 0 { + // we're out of time + return gottenObj, false, errWaitTimeoutWithName + } + + ctx, cancel := watchtools.ContextWithOptionalTimeout(context.Background(), o.Timeout) + watchEvent, err := watchtools.UntilWithoutRetry(ctx, objWatch, Wait{errOut: o.ErrOut}.IsDeleted) + cancel() + switch { + case err == nil: + return watchEvent.Object, true, nil + case err == watchtools.ErrWatchClosed: + continue + case err == wait.ErrWaitTimeout: + if watchEvent != nil { + return watchEvent.Object, false, errWaitTimeoutWithName + } + return gottenObj, false, errWaitTimeoutWithName + default: + return gottenObj, false, err + } + } +} + +// Wait has helper methods for handling watches, including error handling. +type Wait struct { + errOut io.Writer +} + +// IsDeleted returns true if the object is deleted. It prints any errors it encounters. +func (w Wait) IsDeleted(event watch.Event) (bool, error) { + switch event.Type { + case watch.Error: + // keep waiting in the event we see an error - we expect the watch to be closed by + // the server if the error is unrecoverable. + err := apierrors.FromObject(event.Object) + fmt.Fprintf(w.errOut, "error: An error occurred while waiting for the object to be deleted: %v", err) + return false, nil + case watch.Deleted: + return true, nil + default: + return false, nil + } +} + +// ConditionalWait hold information to check an API status condition +type ConditionalWait struct { + conditionName string + conditionStatus string + // errOut is written to if an error occurs + errOut io.Writer +} + +// IsConditionMet is a conditionfunc for waiting on an API condition to be met +func (w ConditionalWait) IsConditionMet(info *resource.Info, o *WaitOptions) (runtime.Object, bool, error) { + endTime := time.Now().Add(o.Timeout) + for { + if len(info.Name) == 0 { + return info.Object, false, fmt.Errorf("resource name must be provided") + } + + nameSelector := fields.OneTermEqualSelector("metadata.name", info.Name).String() + + var gottenObj *unstructured.Unstructured + // List with a name field selector to get the current resourceVersion to watch from (not the object's resourceVersion) + gottenObjList, err := o.DynamicClient.Resource(info.Mapping.Resource).Namespace(info.Namespace).List(metav1.ListOptions{FieldSelector: nameSelector}) + + resourceVersion := "" + switch { + case err != nil: + return info.Object, false, err + case len(gottenObjList.Items) != 1: + resourceVersion = gottenObjList.GetResourceVersion() + default: + gottenObj = &gottenObjList.Items[0] + conditionMet, err := w.checkCondition(gottenObj) + if conditionMet { + return gottenObj, true, nil + } + if err != nil { + return gottenObj, false, err + } + resourceVersion = gottenObjList.GetResourceVersion() + } + + watchOptions := metav1.ListOptions{} + watchOptions.FieldSelector = nameSelector + watchOptions.ResourceVersion = resourceVersion + objWatch, err := o.DynamicClient.Resource(info.Mapping.Resource).Namespace(info.Namespace).Watch(watchOptions) + if err != nil { + return gottenObj, false, err + } + + timeout := endTime.Sub(time.Now()) + errWaitTimeoutWithName := extendErrWaitTimeout(wait.ErrWaitTimeout, info) + if timeout < 0 { + // we're out of time + return gottenObj, false, errWaitTimeoutWithName + } + + ctx, cancel := watchtools.ContextWithOptionalTimeout(context.Background(), o.Timeout) + watchEvent, err := watchtools.UntilWithoutRetry(ctx, objWatch, w.isConditionMet) + cancel() + switch { + case err == nil: + return watchEvent.Object, true, nil + case err == watchtools.ErrWatchClosed: + continue + case err == wait.ErrWaitTimeout: + if watchEvent != nil { + return watchEvent.Object, false, errWaitTimeoutWithName + } + return gottenObj, false, errWaitTimeoutWithName + default: + return gottenObj, false, err + } + } +} + +func (w ConditionalWait) checkCondition(obj *unstructured.Unstructured) (bool, error) { + conditions, found, err := unstructured.NestedSlice(obj.Object, "status", "conditions") + if err != nil { + return false, err + } + if !found { + return false, nil + } + for _, conditionUncast := range conditions { + condition := conditionUncast.(map[string]interface{}) + name, found, err := unstructured.NestedString(condition, "type") + if !found || err != nil || strings.ToLower(name) != strings.ToLower(w.conditionName) { + continue + } + status, found, err := unstructured.NestedString(condition, "status") + if !found || err != nil { + continue + } + return strings.ToLower(status) == strings.ToLower(w.conditionStatus), nil + } + + return false, nil +} + +func (w ConditionalWait) isConditionMet(event watch.Event) (bool, error) { + if event.Type == watch.Error { + // keep waiting in the event we see an error - we expect the watch to be closed by + // the server + err := apierrors.FromObject(event.Object) + fmt.Fprintf(w.errOut, "error: An error occurred while waiting for the condition to be satisfied: %v", err) + return false, nil + } + if event.Type == watch.Deleted { + // this will chain back out, result in another get and an return false back up the chain + return false, nil + } + obj := event.Object.(*unstructured.Unstructured) + return w.checkCondition(obj) +} + +func extendErrWaitTimeout(err error, info *resource.Info) error { + return fmt.Errorf("%s on %s/%s", err.Error(), info.Mapping.Resource.Resource, info.Name) +} diff --git a/pkg/cmd/wait/wait_test.go b/pkg/cmd/wait/wait_test.go new file mode 100644 index 000000000..4e96eb0f7 --- /dev/null +++ b/pkg/cmd/wait/wait_test.go @@ -0,0 +1,789 @@ +/* +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 wait + +import ( + "io/ioutil" + "strings" + "testing" + "time" + + "github.com/davecgh/go-spew/spew" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/cli-runtime/pkg/resource" + dynamicfakeclient "k8s.io/client-go/dynamic/fake" + clienttesting "k8s.io/client-go/testing" +) + +func newUnstructuredList(items ...*unstructured.Unstructured) *unstructured.UnstructuredList { + list := &unstructured.UnstructuredList{} + for i := range items { + list.Items = append(list.Items, *items[i]) + } + return list +} + +func newUnstructured(apiVersion, kind, namespace, name string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "namespace": namespace, + "name": name, + "uid": "some-UID-value", + }, + }, + } +} + +func newUnstructuredStatus(status *metav1.Status) runtime.Unstructured { + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(status) + if err != nil { + panic(err) + } + return &unstructured.Unstructured{ + Object: obj, + } +} + +func addCondition(in *unstructured.Unstructured, name, status string) *unstructured.Unstructured { + conditions, _, _ := unstructured.NestedSlice(in.Object, "status", "conditions") + conditions = append(conditions, map[string]interface{}{ + "type": name, + "status": status, + }) + unstructured.SetNestedSlice(in.Object, conditions, "status", "conditions") + return in +} + +func TestWaitForDeletion(t *testing.T) { + scheme := runtime.NewScheme() + + tests := []struct { + name string + infos []*resource.Info + fakeClient func() *dynamicfakeclient.FakeDynamicClient + timeout time.Duration + uidMap UIDMap + + expectedErr string + validateActions func(t *testing.T, actions []clienttesting.Action) + }{ + { + name: "missing on get", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + return dynamicfakeclient.NewSimpleDynamicClient(scheme) + }, + timeout: 10 * time.Second, + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 1 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "handles no infos", + infos: []*resource.Info{}, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + return dynamicfakeclient.NewSimpleDynamicClient(scheme) + }, + timeout: 10 * time.Second, + expectedErr: errNoMatchingResources.Error(), + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 0 { + t.Fatal(spew.Sdump(actions)) + } + }, + }, + { + name: "uid conflict on get", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, newUnstructuredList(newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")), nil + }) + count := 0 + fakeClient.PrependWatchReactor("theresource", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) { + if count == 0 { + count++ + fakeWatch := watch.NewRaceFreeFake() + go func() { + time.Sleep(100 * time.Millisecond) + fakeWatch.Stop() + }() + return true, fakeWatch, nil + } + fakeWatch := watch.NewRaceFreeFake() + return true, fakeWatch, nil + }) + return fakeClient + }, + timeout: 10 * time.Second, + uidMap: UIDMap{ + ResourceLocation{Namespace: "ns-foo", Name: "name-foo"}: types.UID("some-UID-value"), + ResourceLocation{GroupResource: schema.GroupResource{Group: "group", Resource: "theresource"}, Namespace: "ns-foo", Name: "name-foo"}: types.UID("some-nonmatching-UID-value"), + }, + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 1 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "times out", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, newUnstructuredList(newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")), nil + }) + return fakeClient + }, + timeout: 1 * time.Second, + + expectedErr: "timed out waiting for the condition on theresource/name-foo", + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 2 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[1].Matches("watch", "theresource") { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "handles watch close out", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + unstructuredObj := newUnstructured("group/version", "TheKind", "ns-foo", "name-foo") + unstructuredObj.SetResourceVersion("123") + unstructuredList := newUnstructuredList(unstructuredObj) + unstructuredList.SetResourceVersion("234") + return true, unstructuredList, nil + }) + count := 0 + fakeClient.PrependWatchReactor("theresource", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) { + if count == 0 { + count++ + fakeWatch := watch.NewRaceFreeFake() + go func() { + time.Sleep(100 * time.Millisecond) + fakeWatch.Stop() + }() + return true, fakeWatch, nil + } + fakeWatch := watch.NewRaceFreeFake() + return true, fakeWatch, nil + }) + return fakeClient + }, + timeout: 3 * time.Second, + + expectedErr: "timed out waiting for the condition on theresource/name-foo", + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 4 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[1].Matches("watch", "theresource") || actions[1].(clienttesting.WatchAction).GetWatchRestrictions().ResourceVersion != "234" { + t.Error(spew.Sdump(actions)) + } + if !actions[2].Matches("list", "theresource") || actions[2].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[3].Matches("watch", "theresource") || actions[3].(clienttesting.WatchAction).GetWatchRestrictions().ResourceVersion != "234" { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "handles watch delete", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, newUnstructuredList(newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")), nil + }) + fakeClient.PrependWatchReactor("theresource", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) { + fakeWatch := watch.NewRaceFreeFake() + fakeWatch.Action(watch.Deleted, newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")) + return true, fakeWatch, nil + }) + return fakeClient + }, + timeout: 10 * time.Second, + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 2 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[1].Matches("watch", "theresource") { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "handles watch delete multiple", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource-1"}, + }, + Name: "name-foo-1", + Namespace: "ns-foo", + }, + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource-2"}, + }, + Name: "name-foo-2", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("get", "theresource-1", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, newUnstructured("group/version", "TheKind", "ns-foo", "name-foo-1"), nil + }) + fakeClient.PrependReactor("get", "theresource-2", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, newUnstructured("group/version", "TheKind", "ns-foo", "name-foo-2"), nil + }) + fakeClient.PrependWatchReactor("theresource-1", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) { + fakeWatch := watch.NewRaceFreeFake() + fakeWatch.Action(watch.Deleted, newUnstructured("group/version", "TheKind", "ns-foo", "name-foo-1")) + return true, fakeWatch, nil + }) + fakeClient.PrependWatchReactor("theresource-2", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) { + fakeWatch := watch.NewRaceFreeFake() + fakeWatch.Action(watch.Deleted, newUnstructured("group/version", "TheKind", "ns-foo", "name-foo-2")) + return true, fakeWatch, nil + }) + return fakeClient + }, + timeout: 10 * time.Second, + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 2 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource-1") { + t.Error(spew.Sdump(actions)) + } + if !actions[1].Matches("list", "theresource-2") { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "ignores watch error", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, newUnstructuredList(newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")), nil + }) + count := 0 + fakeClient.PrependWatchReactor("theresource", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) { + fakeWatch := watch.NewRaceFreeFake() + if count == 0 { + fakeWatch.Error(newUnstructuredStatus(&metav1.Status{ + TypeMeta: metav1.TypeMeta{Kind: "Status", APIVersion: "v1"}, + Status: "Failure", + Code: 500, + Message: "Bad", + })) + fakeWatch.Stop() + } else { + fakeWatch.Action(watch.Deleted, newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")) + } + count++ + return true, fakeWatch, nil + }) + return fakeClient + }, + timeout: 10 * time.Second, + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 4 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[1].Matches("watch", "theresource") { + t.Error(spew.Sdump(actions)) + } + if !actions[2].Matches("list", "theresource") || actions[2].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[3].Matches("watch", "theresource") { + t.Error(spew.Sdump(actions)) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fakeClient := test.fakeClient() + o := &WaitOptions{ + ResourceFinder: genericclioptions.NewSimpleFakeResourceFinder(test.infos...), + UIDMap: test.uidMap, + DynamicClient: fakeClient, + Timeout: test.timeout, + + Printer: printers.NewDiscardingPrinter(), + ConditionFn: IsDeleted, + IOStreams: genericclioptions.NewTestIOStreamsDiscard(), + } + err := o.RunWait() + switch { + case err == nil && len(test.expectedErr) == 0: + case err != nil && len(test.expectedErr) == 0: + t.Fatal(err) + case err == nil && len(test.expectedErr) != 0: + t.Fatalf("missing: %q", test.expectedErr) + case err != nil && len(test.expectedErr) != 0: + if !strings.Contains(err.Error(), test.expectedErr) { + t.Fatalf("expected %q, got %q", test.expectedErr, err.Error()) + } + } + + test.validateActions(t, fakeClient.Actions()) + }) + } +} + +func TestWaitForCondition(t *testing.T) { + scheme := runtime.NewScheme() + + tests := []struct { + name string + infos []*resource.Info + fakeClient func() *dynamicfakeclient.FakeDynamicClient + timeout time.Duration + + expectedErr string + validateActions func(t *testing.T, actions []clienttesting.Action) + }{ + { + name: "present on get", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, newUnstructuredList(addCondition( + newUnstructured("group/version", "TheKind", "ns-foo", "name-foo"), + "the-condition", "status-value", + )), nil + }) + return fakeClient + }, + timeout: 10 * time.Second, + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 1 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "handles no infos", + infos: []*resource.Info{}, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + return dynamicfakeclient.NewSimpleDynamicClient(scheme) + }, + timeout: 10 * time.Second, + expectedErr: errNoMatchingResources.Error(), + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 0 { + t.Fatal(spew.Sdump(actions)) + } + }, + }, + { + name: "handles empty object name", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + return dynamicfakeclient.NewSimpleDynamicClient(scheme) + }, + timeout: 10 * time.Second, + expectedErr: "resource name must be provided", + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 0 { + t.Fatal(spew.Sdump(actions)) + } + }, + }, + { + name: "times out", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, addCondition( + newUnstructured("group/version", "TheKind", "ns-foo", "name-foo"), + "some-other-condition", "status-value", + ), nil + }) + return fakeClient + }, + timeout: 1 * time.Second, + + expectedErr: "timed out waiting for the condition on theresource/name-foo", + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 2 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[1].Matches("watch", "theresource") { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "handles watch close out", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + unstructuredObj := newUnstructured("group/version", "TheKind", "ns-foo", "name-foo") + unstructuredObj.SetResourceVersion("123") + unstructuredList := newUnstructuredList(unstructuredObj) + unstructuredList.SetResourceVersion("234") + return true, unstructuredList, nil + }) + count := 0 + fakeClient.PrependWatchReactor("theresource", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) { + if count == 0 { + count++ + fakeWatch := watch.NewRaceFreeFake() + go func() { + time.Sleep(100 * time.Millisecond) + fakeWatch.Stop() + }() + return true, fakeWatch, nil + } + fakeWatch := watch.NewRaceFreeFake() + return true, fakeWatch, nil + }) + return fakeClient + }, + timeout: 3 * time.Second, + + expectedErr: "timed out waiting for the condition on theresource/name-foo", + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 4 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[1].Matches("watch", "theresource") || actions[1].(clienttesting.WatchAction).GetWatchRestrictions().ResourceVersion != "234" { + t.Error(spew.Sdump(actions)) + } + if !actions[2].Matches("list", "theresource") || actions[2].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[3].Matches("watch", "theresource") || actions[3].(clienttesting.WatchAction).GetWatchRestrictions().ResourceVersion != "234" { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "handles watch condition change", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, newUnstructuredList(newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")), nil + }) + fakeClient.PrependWatchReactor("theresource", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) { + fakeWatch := watch.NewRaceFreeFake() + fakeWatch.Action(watch.Modified, addCondition( + newUnstructured("group/version", "TheKind", "ns-foo", "name-foo"), + "the-condition", "status-value", + )) + return true, fakeWatch, nil + }) + return fakeClient + }, + timeout: 10 * time.Second, + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 2 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[1].Matches("watch", "theresource") { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "handles watch created", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependWatchReactor("theresource", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) { + fakeWatch := watch.NewRaceFreeFake() + fakeWatch.Action(watch.Added, addCondition( + newUnstructured("group/version", "TheKind", "ns-foo", "name-foo"), + "the-condition", "status-value", + )) + return true, fakeWatch, nil + }) + return fakeClient + }, + timeout: 10 * time.Second, + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 2 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[1].Matches("watch", "theresource") { + t.Error(spew.Sdump(actions)) + } + }, + }, + { + name: "ignores watch error", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + fakeClient := dynamicfakeclient.NewSimpleDynamicClient(scheme) + fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, newUnstructuredList(newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")), nil + }) + count := 0 + fakeClient.PrependWatchReactor("theresource", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) { + fakeWatch := watch.NewRaceFreeFake() + if count == 0 { + fakeWatch.Error(newUnstructuredStatus(&metav1.Status{ + TypeMeta: metav1.TypeMeta{Kind: "Status", APIVersion: "v1"}, + Status: "Failure", + Code: 500, + Message: "Bad", + })) + fakeWatch.Stop() + } else { + fakeWatch.Action(watch.Modified, addCondition( + newUnstructured("group/version", "TheKind", "ns-foo", "name-foo"), + "the-condition", "status-value", + )) + } + count++ + return true, fakeWatch, nil + }) + return fakeClient + }, + timeout: 10 * time.Second, + + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 4 { + t.Fatal(spew.Sdump(actions)) + } + if !actions[0].Matches("list", "theresource") || actions[0].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[1].Matches("watch", "theresource") { + t.Error(spew.Sdump(actions)) + } + if !actions[2].Matches("list", "theresource") || actions[2].(clienttesting.ListAction).GetListRestrictions().Fields.String() != "metadata.name=name-foo" { + t.Error(spew.Sdump(actions)) + } + if !actions[3].Matches("watch", "theresource") { + t.Error(spew.Sdump(actions)) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fakeClient := test.fakeClient() + o := &WaitOptions{ + ResourceFinder: genericclioptions.NewSimpleFakeResourceFinder(test.infos...), + DynamicClient: fakeClient, + Timeout: test.timeout, + + Printer: printers.NewDiscardingPrinter(), + ConditionFn: ConditionalWait{conditionName: "the-condition", conditionStatus: "status-value", errOut: ioutil.Discard}.IsConditionMet, + IOStreams: genericclioptions.NewTestIOStreamsDiscard(), + } + err := o.RunWait() + switch { + case err == nil && len(test.expectedErr) == 0: + case err != nil && len(test.expectedErr) == 0: + t.Fatal(err) + case err == nil && len(test.expectedErr) != 0: + t.Fatalf("missing: %q", test.expectedErr) + case err != nil && len(test.expectedErr) != 0: + if !strings.Contains(err.Error(), test.expectedErr) { + t.Fatalf("expected %q, got %q", test.expectedErr, err.Error()) + } + } + + test.validateActions(t, fakeClient.Actions()) + }) + } +}