From 7ac20a48ed61b65bebbe209be0f97117c8a792f8 Mon Sep 17 00:00:00 2001 From: Matous Jobanek Date: Thu, 14 Mar 2024 10:56:50 +0100 Subject: [PATCH] move ksctl code (#4) --- cmd/ksctl/main.go | 19 + cmd/sandbox-cli/main.go | 19 + go.mod | 127 ++ go.sum | 1260 +++++++++++++++++ pkg/assets/assets.go | 68 + pkg/assets/assets_test.go | 157 ++ pkg/assets/sandbox_config.go | 51 + pkg/client/client.go | 377 +++++ pkg/client/client_test.go | 583 ++++++++ pkg/client/command_creator.go | 5 + pkg/client/sandbox_config_file.go | 33 + pkg/cmd/add_space_users.go | 115 ++ pkg/cmd/add_space_users_test.go | 250 ++++ pkg/cmd/adm/adm.go | 32 + pkg/cmd/adm/generate_cli_configs.go | 271 ++++ pkg/cmd/adm/generate_cli_configs_test.go | 368 +++++ pkg/cmd/adm/must_gather_namespace.go | 206 +++ pkg/cmd/adm/must_gather_namespace_test.go | 371 +++++ pkg/cmd/adm/register_member.go | 162 +++ pkg/cmd/adm/register_member_test.go | 229 +++ pkg/cmd/adm/restart.go | 158 +++ pkg/cmd/adm/restart_test.go | 253 ++++ pkg/cmd/adm/setup.go | 103 ++ pkg/cmd/adm/setup_assertion_test.go | 435 ++++++ pkg/cmd/adm/setup_cluster.go | 58 + pkg/cmd/adm/setup_cluster_test.go | 161 +++ pkg/cmd/adm/setup_mock_test.go | 75 + pkg/cmd/adm/setup_permissions.go | 245 ++++ pkg/cmd/adm/setup_permissions_test.go | 208 +++ pkg/cmd/adm/setup_roles_manager.go | 53 + pkg/cmd/adm/setup_roles_manager_test.go | 220 +++ pkg/cmd/adm/setup_test.go | 351 +++++ pkg/cmd/adm/setup_util.go | 210 +++ pkg/cmd/adm/setup_util_test.go | 298 ++++ pkg/cmd/adm/unregister_member.go | 66 + pkg/cmd/adm/unregister_member_test.go | 123 ++ pkg/cmd/approve.go | 144 ++ pkg/cmd/approve_test.go | 403 ++++++ pkg/cmd/ban.go | 125 ++ pkg/cmd/ban_test.go | 229 +++ pkg/cmd/base_kubectl.go | 71 + pkg/cmd/create_social_event.go | 119 ++ pkg/cmd/create_social_event_test.go | 163 +++ pkg/cmd/deactivate.go | 41 + pkg/cmd/deactivate_test.go | 75 + pkg/cmd/delete.go | 62 + pkg/cmd/delete_test.go | 122 ++ pkg/cmd/describe.go | 14 + pkg/cmd/describe_test.go | 172 +++ pkg/cmd/disable_user.go | 39 + pkg/cmd/disable_user_test.go | 77 + pkg/cmd/flags/flags.go | 17 + pkg/cmd/get.go | 14 + pkg/cmd/get_test.go | 170 +++ pkg/cmd/logs.go | 14 + pkg/cmd/logs_test.go | 174 +++ pkg/cmd/promote_space.go | 60 + pkg/cmd/promote_space_test.go | 131 ++ pkg/cmd/promote_user.go | 60 + pkg/cmd/promote_user_test.go | 118 ++ pkg/cmd/remove_space_users.go | 93 ++ pkg/cmd/remove_space_users_test.go | 206 +++ pkg/cmd/retarget.go | 106 ++ pkg/cmd/retarget_test.go | 174 +++ pkg/cmd/root.go | 67 + pkg/cmd/status.go | 61 + pkg/cmd/status_test.go | 143 ++ pkg/configuration/configuration.go | 231 +++ pkg/configuration/configuration_test.go | 350 +++++ pkg/context/clusterconfig_command_context.go | 37 + .../clusterconfig_command_context_test.go | 57 + pkg/context/command_context.go | 32 + pkg/context/command_context_test.go | 42 + pkg/ioutils/terminal.go | 155 ++ pkg/ioutils/terminal_test.go | 109 ++ pkg/test/banneduser.go | 30 + pkg/test/client.go | 93 ++ pkg/test/config.go | 127 ++ pkg/test/environment_config.go | 152 ++ pkg/test/fake_files.go | 139 ++ pkg/test/fake_terminal.go | 65 + pkg/test/object_assertions.go | 227 +++ pkg/test/toolchaincluster.go | 53 + pkg/test/usersignup.go | 119 ++ pkg/utils/case.go | 33 + pkg/utils/case_test.go | 45 + pkg/utils/util.go | 11 + pkg/utils/util_test.go | 30 + pkg/version/version.go | 17 + pkg/version/version_test.go | 19 + resources/resources.go | 8 + resources/setup/roles/host.yaml | 315 +++++ resources/setup/roles/member.yaml | 168 +++ .../sandbox-config.yaml | 216 +++ 94 files changed, 14064 insertions(+) create mode 100644 cmd/ksctl/main.go create mode 100644 cmd/sandbox-cli/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/assets/assets.go create mode 100644 pkg/assets/assets_test.go create mode 100644 pkg/assets/sandbox_config.go create mode 100644 pkg/client/client.go create mode 100644 pkg/client/client_test.go create mode 100644 pkg/client/command_creator.go create mode 100644 pkg/client/sandbox_config_file.go create mode 100644 pkg/cmd/add_space_users.go create mode 100644 pkg/cmd/add_space_users_test.go create mode 100644 pkg/cmd/adm/adm.go create mode 100644 pkg/cmd/adm/generate_cli_configs.go create mode 100644 pkg/cmd/adm/generate_cli_configs_test.go create mode 100644 pkg/cmd/adm/must_gather_namespace.go create mode 100644 pkg/cmd/adm/must_gather_namespace_test.go create mode 100644 pkg/cmd/adm/register_member.go create mode 100644 pkg/cmd/adm/register_member_test.go create mode 100644 pkg/cmd/adm/restart.go create mode 100644 pkg/cmd/adm/restart_test.go create mode 100644 pkg/cmd/adm/setup.go create mode 100644 pkg/cmd/adm/setup_assertion_test.go create mode 100644 pkg/cmd/adm/setup_cluster.go create mode 100644 pkg/cmd/adm/setup_cluster_test.go create mode 100644 pkg/cmd/adm/setup_mock_test.go create mode 100644 pkg/cmd/adm/setup_permissions.go create mode 100644 pkg/cmd/adm/setup_permissions_test.go create mode 100644 pkg/cmd/adm/setup_roles_manager.go create mode 100644 pkg/cmd/adm/setup_roles_manager_test.go create mode 100644 pkg/cmd/adm/setup_test.go create mode 100644 pkg/cmd/adm/setup_util.go create mode 100644 pkg/cmd/adm/setup_util_test.go create mode 100644 pkg/cmd/adm/unregister_member.go create mode 100644 pkg/cmd/adm/unregister_member_test.go create mode 100644 pkg/cmd/approve.go create mode 100644 pkg/cmd/approve_test.go create mode 100644 pkg/cmd/ban.go create mode 100644 pkg/cmd/ban_test.go create mode 100644 pkg/cmd/base_kubectl.go create mode 100644 pkg/cmd/create_social_event.go create mode 100644 pkg/cmd/create_social_event_test.go create mode 100644 pkg/cmd/deactivate.go create mode 100644 pkg/cmd/deactivate_test.go create mode 100644 pkg/cmd/delete.go create mode 100644 pkg/cmd/delete_test.go create mode 100644 pkg/cmd/describe.go create mode 100644 pkg/cmd/describe_test.go create mode 100644 pkg/cmd/disable_user.go create mode 100644 pkg/cmd/disable_user_test.go create mode 100644 pkg/cmd/flags/flags.go create mode 100644 pkg/cmd/get.go create mode 100644 pkg/cmd/get_test.go create mode 100644 pkg/cmd/logs.go create mode 100644 pkg/cmd/logs_test.go create mode 100644 pkg/cmd/promote_space.go create mode 100644 pkg/cmd/promote_space_test.go create mode 100644 pkg/cmd/promote_user.go create mode 100644 pkg/cmd/promote_user_test.go create mode 100644 pkg/cmd/remove_space_users.go create mode 100644 pkg/cmd/remove_space_users_test.go create mode 100644 pkg/cmd/retarget.go create mode 100644 pkg/cmd/retarget_test.go create mode 100644 pkg/cmd/root.go create mode 100644 pkg/cmd/status.go create mode 100644 pkg/cmd/status_test.go create mode 100644 pkg/configuration/configuration.go create mode 100644 pkg/configuration/configuration_test.go create mode 100644 pkg/context/clusterconfig_command_context.go create mode 100644 pkg/context/clusterconfig_command_context_test.go create mode 100644 pkg/context/command_context.go create mode 100644 pkg/context/command_context_test.go create mode 100644 pkg/ioutils/terminal.go create mode 100644 pkg/ioutils/terminal_test.go create mode 100644 pkg/test/banneduser.go create mode 100644 pkg/test/client.go create mode 100644 pkg/test/config.go create mode 100644 pkg/test/environment_config.go create mode 100644 pkg/test/fake_files.go create mode 100644 pkg/test/fake_terminal.go create mode 100644 pkg/test/object_assertions.go create mode 100644 pkg/test/toolchaincluster.go create mode 100644 pkg/test/usersignup.go create mode 100644 pkg/utils/case.go create mode 100644 pkg/utils/case_test.go create mode 100644 pkg/utils/util.go create mode 100644 pkg/utils/util_test.go create mode 100644 pkg/version/version.go create mode 100644 pkg/version/version_test.go create mode 100644 resources/resources.go create mode 100644 resources/setup/roles/host.yaml create mode 100644 resources/setup/roles/member.yaml create mode 100644 test-resources/dummy.openshiftapps.com/sandbox-config.yaml diff --git a/cmd/ksctl/main.go b/cmd/ksctl/main.go new file mode 100644 index 0000000..0db587e --- /dev/null +++ b/cmd/ksctl/main.go @@ -0,0 +1,19 @@ +// 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 "github.com/kubesaw/ksctl/pkg/cmd" + +func main() { + cmd.Execute() +} diff --git a/cmd/sandbox-cli/main.go b/cmd/sandbox-cli/main.go new file mode 100644 index 0000000..0db587e --- /dev/null +++ b/cmd/sandbox-cli/main.go @@ -0,0 +1,19 @@ +// 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 "github.com/kubesaw/ksctl/pkg/cmd" + +func main() { + cmd.Execute() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..625ecea --- /dev/null +++ b/go.mod @@ -0,0 +1,127 @@ +module github.com/kubesaw/ksctl + +go 1.20 + +require ( + github.com/codeready-toolchain/api v0.0.0-20240227210924-371ddb054d87 + github.com/codeready-toolchain/toolchain-common v0.0.0-20240227212148-b32711b41532 + github.com/ghodss/yaml v1.0.0 + github.com/mitchellh/go-homedir v1.1.0 + // using latest commit from 'github.com/openshift/api branch release-4.12' + github.com/openshift/api v0.0.0-20230213134911-7ba313770556 + github.com/operator-framework/api v0.13.0 + github.com/pkg/errors v0.9.1 + github.com/satori/go.uuid v1.2.0 + github.com/spf13/cobra v1.8.0 + github.com/stretchr/testify v1.8.4 + golang.org/x/term v0.15.0 + gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.25.0 + k8s.io/apimachinery v0.25.0 + k8s.io/cli-runtime v0.25.0 + k8s.io/client-go v0.25.0 + k8s.io/kubectl v0.25.0 + k8s.io/utils v0.0.0-20220728103510-ee6ede2d64ed + sigs.k8s.io/controller-runtime v0.13.0 + sigs.k8s.io/kustomize/api v0.12.1 +) + +require github.com/h2non/gock v1.2.0 + +require ( + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/BurntSushi/toml v0.4.1 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.1.1 // indirect + github.com/Masterminds/sprig/v3 v3.2.2 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.8.0 // indirect + github.com/evanphx/json-patch v5.6.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.6.0 // indirect + github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect + github.com/fatih/camelcase v1.0.0 // indirect + github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/fvbommel/sortorder v1.0.1 // indirect + github.com/go-errors/errors v1.0.1 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.5 // indirect + github.com/go-openapi/swag v0.19.14 // indirect + github.com/gofrs/uuid v3.3.0+incompatible // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/btree v1.0.1 // indirect + github.com/google/gnostic v0.5.7-v3refs // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/google/go-github/v52 v52.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/uuid v1.1.2 // indirect + github.com/gorilla/mux v1.8.0 // indirect + github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect + github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect + github.com/huandu/xstrings v1.3.1 // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect + github.com/migueleliasweb/go-github-mock v0.0.18 // indirect + github.com/mitchellh/copystructure v1.0.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.0 // indirect + github.com/mitchellh/reflectwalk v1.0.0 // indirect + github.com/moby/spdystream v0.2.0 // indirect + github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/openshift/library-go v0.0.0-20230301092340-c13b89190a26 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.12.2 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.32.1 // indirect + github.com/prometheus/procfs v0.7.3 // indirect + github.com/redhat-cop/operator-utils v1.3.3-0.20220121120056-862ef22b8cdf // indirect + github.com/russross/blackfriday v1.5.2 // indirect + github.com/shopspring/decimal v1.2.0 // indirect + github.com/sirupsen/logrus v1.8.1 // indirect + github.com/spf13/cast v1.3.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/xlab/treeprint v1.1.0 // indirect + go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/oauth2 v0.7.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.3.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.28.0 // indirect + gopkg.in/h2non/gock.v1 v1.0.14 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiextensions-apiserver v0.25.0 // indirect + k8s.io/component-base v0.25.0 // indirect + k8s.io/klog/v2 v2.80.0 // indirect + k8s.io/kube-openapi v0.0.0-20220803162953-67bda5d908f1 // indirect + sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect + sigs.k8s.io/kustomize/kyaml v0.13.9 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3cfe4de --- /dev/null +++ b/go.sum @@ -0,0 +1,1260 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-ansiterm v0.0.0-20210608223527-2377c96fe795/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.12/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw= +github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= +github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= +github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= +github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= +github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20210826220005-b48c857c3a0e/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= +github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo= +github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA= +github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI= +github.com/codeready-toolchain/api v0.0.0-20240227210924-371ddb054d87 h1:eQLsrMqfjAzGfuO9t6pVxO4K6cUDKOMxEvl0ujQq/2I= +github.com/codeready-toolchain/api v0.0.0-20240227210924-371ddb054d87/go.mod h1:FO7kgXH1x1LqkF327D5a36u0WIrwjVCbeijPkzgwaZc= +github.com/codeready-toolchain/toolchain-common v0.0.0-20240227212148-b32711b41532 h1:mfTiDF9af5hmc23AR1DreoCcyLTeAJKhYbFPyd7f/+Q= +github.com/codeready-toolchain/toolchain-common v0.0.0-20240227212148-b32711b41532/go.mod h1:nA1+TOD7zDS6spCBTaIZ63B/KyysR66fJI3DUT86kKE= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= +github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1:dv4zxwHi5C/8AeI+4gX4dCWOIvNi7I6JCSX0HvlKPgE= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153 h1:yUdfgN0XgIJw7foRItutHYUIhlcKzcSf5vDpdhQAKTc= +github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful/v3 v3.8.0 h1:eCZ8ulSerjdAiaNpF7GxXIE7ZCMo1moN1qX+S609eVw= +github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= +github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM= +github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= +github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= +github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fvbommel/sortorder v1.0.1 h1:dSnXLt4mJYH25uDDGa3biZNQsozaUWDSWeKJ0qqFfzE= +github.com/fvbommel/sortorder v1.0.1/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-bindata/go-bindata/v3 v3.1.3/go.mod h1:1/zrpXsLD8YDIbhZRqXzm1Ghc7NhEvIN9+Z6R5/xH4I= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/zapr v0.4.0/go.mod h1:tabnROwaDl0UNxkVeFRbY8bwB37GwRv0P8lg6aAiEnk= +github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A= +github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM= +github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= +github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= +github.com/go-openapi/spec v0.19.5/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng= +github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/gobuffalo/flect v0.2.3/go.mod h1:vmkQwuZYhN5Pc4ljYQZzP+1sq+NEkK+lh20jmEmX3jc= +github.com/goccy/go-yaml v1.8.1/go.mod h1:wS4gNoLalDSJxo/SpngzPQ2BN4uuZVLCmbM4S3vd4+Y= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e/go.mod h1:0AA//k/eakGydO4jKRoRL2j92ZKSzTgj9tclaCrvXHk= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= +github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= +github.com/google/cel-go v0.9.0/go.mod h1:U7ayypeSkw23szu4GaQTPJGx66c20mx8JklMSxrmI1w= +github.com/google/cel-spec v0.6.0/go.mod h1:Nwjgxy5CbjlPrtCWjeDjUyKMl8w41YBYGjsyDdqk0xA= +github.com/google/gnostic v0.5.7-v3refs h1:FhTMOKj2VhjpouxvWJAV1TL304uMlb9zcDqkl6cEI54= +github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v52 v52.0.0 h1:uyGWOY+jMQ8GVGSX8dkSwCzlehU3WfdxQ7GweO/JP7M= +github.com/google/go-github/v52 v52.0.0/go.mod h1:WJV6VEEUPuMo5pXqqa2ZCZEdbQqua4zAk2MZTIo+m+4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= +github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= +github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= +github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs= +github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= +github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/migueleliasweb/go-github-mock v0.0.18 h1:0lWt9MYmZQGnQE2rFtjlft/YtD6hzxuN6JJRFpujzEI= +github.com/migueleliasweb/go-github-mock v0.0.18/go.mod h1:CcgXcbMoRnf3rRVHqGssuBquZDIcaplxL2W6G+xs7kM= +github.com/mikefarah/yq/v3 v3.0.0-20201202084205-8846255d1c37/go.mod h1:dYWq+UWoFCDY1TndvFUQuhBbIYmZpjreC8adEAx93zE= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= +github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= +github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc= +github.com/moby/term v0.0.0-20210610120745-9d4ed1856297/go.mod h1:vgPCkQMyxTZ7IDy8SXRufE172gr8+K/JE/7hHFxHW3A= +github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= +github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249/go.mod h1:mpRZBD8SJ55OIICQ3iWH0Yz3cjzA61JdqMLoWXeB2+8= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.1.4 h1:GNapqRSid3zijZ9H77KrgVG4/8KqiyRsxcSxe+7ApXY= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.14.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= +github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/openshift/api v0.0.0-20230213134911-7ba313770556 h1:7W2fOhJicyEff24VaF7ASNzPtYvr+iSCVft4SIBAzaE= +github.com/openshift/api v0.0.0-20230213134911-7ba313770556/go.mod h1:aQ6LDasvHMvHZXqLHnX2GRmnfTWCF/iIwz8EMTTIE9A= +github.com/openshift/library-go v0.0.0-20230301092340-c13b89190a26 h1:vXYT3dX03Fm5FCX1284aTGoa5qBZFp3zMnIVaV9WOdg= +github.com/openshift/library-go v0.0.0-20230301092340-c13b89190a26/go.mod h1:KPBAXGaq7pPmA+1wUVtKr5Axg3R68IomWDkzaOxIhxM= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/operator-framework/api v0.13.0 h1:V1vUluRwajSBdDPCnzgTWDnn5LYxLk66VPVGMw3B7Uc= +github.com/operator-framework/api v0.13.0/go.mod h1:FTiYGm11fZQ3cSX+EQHc/UWoGZAwkGfyeHU+wMJ8jmA= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.12.2 h1:51L9cDoUHVrXx4zWYlcLQIZ+d+VXHgqnYKkIuq4g/34= +github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/redhat-cop/operator-utils v1.3.3-0.20220121120056-862ef22b8cdf h1:fsZiv9XuFo8G7IyzFWjG02vqzJG7kSqFvD1Wiq3V/o8= +github.com/redhat-cop/operator-utils v1.3.3-0.20220121120056-862ef22b8cdf/go.mod h1:FfTyeSCu+e2VLgeMh/1RFG8TSkVjKRPEyR6EmDt0RIw= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/scylladb/go-set v1.0.2/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= +github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= +github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= +github.com/xlab/treeprint v1.1.0 h1:G/1DjNkPpfZCFt9CSh6b5/nY4VimlbHF3Rh4obvtzDk= +github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489/go.mod h1:yVHk9ub3CSBatqGNg7GRmsnfLWtoW60w4eDYfh7vHDg= +go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= +go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= +go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0= +go.etcd.io/etcd/pkg/v3 v3.5.0/go.mod h1:UzJGatBQ1lXChBkQF0AuAtkRQMYnHubxAEYIrC3MSsE= +go.etcd.io/etcd/raft/v3 v3.5.0/go.mod h1:UFOHSIvO/nKwd4lhkwabrTD3cqW5yVyYYf/KlD00Szc= +go.etcd.io/etcd/server/v3 v3.5.0/go.mod h1:3Ah5ruV+M+7RZr0+Y/5mNLwC+eQlni+mQmOVdCRJoS4= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/contrib v0.20.0/go.mod h1:G/EtFaa6qaN7+LxqfIAT3GiZa7Wv5DTBUzl5H4LY0Kc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0/go.mod h1:oVGt1LRbBOBq1A5BQLlUg9UaU/54aiHw8cgjV3aWZ/E= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.20.0/go.mod h1:2AboqHi0CiIZU0qwhtUfCYD1GeUzvvIXWNkhDt7ZMG4= +go.opentelemetry.io/otel v0.20.0/go.mod h1:Y3ugLH2oa81t5QO+Lty+zXf8zC9L26ax4Nzoxm/dooo= +go.opentelemetry.io/otel/exporters/otlp v0.20.0/go.mod h1:YIieizyaN77rtLJra0buKiNBOm9XQfkPEKBeuhoMwAM= +go.opentelemetry.io/otel/metric v0.20.0/go.mod h1:598I5tYlH1vzBjn+BTuhzTCSb/9debfNp6R3s7Pr1eU= +go.opentelemetry.io/otel/oteltest v0.20.0/go.mod h1:L7bgKf9ZB7qCwT9Up7i9/pn0PWIa9FqQ2IQ8LoxiGnw= +go.opentelemetry.io/otel/sdk v0.20.0/go.mod h1:g/IcepuwNsoiX5Byy2nNV0ySUF1em498m7hBWC279Yc= +go.opentelemetry.io/otel/sdk/export/metric v0.20.0/go.mod h1:h7RBNMsDJ5pmI1zExLi+bJK+Dr8NQCh0qGhm1KDnNlE= +go.opentelemetry.io/otel/sdk/metric v0.20.0/go.mod h1:knxiS8Xd4E/N+ZqKmUPf3gTTZ4/0TjTXukfxjzSTpHE= +go.opentelemetry.io/otel/trace v0.20.0/go.mod h1:6GjCW8zgDjwGHGa6GkyeB8+/5vjT16gUEi0Nf1iBdgw= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 h1:+FNtrFTmVw0YZGpBGX56XDee331t6JAXeK2bcyhLOOc= +go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5/go.mod h1:nmDLcffg48OtT/PSW0Hg7FvpRQsQh5OSqIylirxKC7o= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210224082022-3d97a244fca7/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= +golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191002063906-3421d5a6bb1c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210817190340-bfb29a6856f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.2.0 h1:4pT439QV83L+G9FkcCriY6EkpcK6r6bK+A5FBUMI7qY= +gomodules.xyz/jsonpatch/v2 v2.2.0/go.mod h1:WXp+iVDkoLQqPudfQ9GBlwB2eZ5DKOnjQZCYdOS8GPY= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201102152239-715cce707fb0/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.30.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/h2non/gock.v1 v1.0.14 h1:fTeu9fcUvSnLNacYvYI54h+1/XEteDyHvrVCZEEEYNM= +gopkg.in/h2non/gock.v1 v1.0.14/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473/go.mod h1:N1eN2tsCx0Ydtgjl4cqmbRCsY4/+z4cYDeqwZTk6zog= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +k8s.io/api v0.21.3/go.mod h1:hUgeYHUbBp23Ue4qdX9tR8/ANi/g3ehylAqDn9NWVOg= +k8s.io/api v0.22.1/go.mod h1:bh13rkTp3F1XEaLGykbyRD2QaTTzPm0e/BMd8ptFONY= +k8s.io/api v0.25.0 h1:H+Q4ma2U/ww0iGB78ijZx6DRByPz6/733jIuFpX70e0= +k8s.io/api v0.25.0/go.mod h1:ttceV1GyV1i1rnmvzT3BST08N6nGt+dudGrquzVQWPk= +k8s.io/apiextensions-apiserver v0.21.3/go.mod h1:kl6dap3Gd45+21Jnh6utCx8Z2xxLm8LGDkprcd+KbsE= +k8s.io/apiextensions-apiserver v0.22.1/go.mod h1:HeGmorjtRmRLE+Q8dJu6AYRoZccvCMsghwS8XTUYb2c= +k8s.io/apiextensions-apiserver v0.25.0 h1:CJ9zlyXAbq0FIW8CD7HHyozCMBpDSiH7EdrSTCZcZFY= +k8s.io/apiextensions-apiserver v0.25.0/go.mod h1:3pAjZiN4zw7R8aZC5gR0y3/vCkGlAjCazcg1me8iB/E= +k8s.io/apimachinery v0.21.3/go.mod h1:H/IM+5vH9kZRNJ4l3x/fXP/5bOPJaVP/guptnZPeCFI= +k8s.io/apimachinery v0.22.1/go.mod h1:O3oNtNadZdeOMxHFVxOreoznohCpy0z6mocxbZr7oJ0= +k8s.io/apimachinery v0.25.0 h1:MlP0r6+3XbkUG2itd6vp3oxbtdQLQI94fD5gCS+gnoU= +k8s.io/apimachinery v0.25.0/go.mod h1:qMx9eAk0sZQGsXGu86fab8tZdffHbwUfsvzqKn4mfB0= +k8s.io/apiserver v0.21.3/go.mod h1:eDPWlZG6/cCCMj/JBcEpDoK+I+6i3r9GsChYBHSbAzU= +k8s.io/apiserver v0.22.1/go.mod h1:2mcM6dzSt+XndzVQJX21Gx0/Klo7Aen7i0Ai6tIa400= +k8s.io/cli-runtime v0.22.1/go.mod h1:YqwGrlXeEk15Yn3em2xzr435UGwbrCw5x+COQoTYfoo= +k8s.io/cli-runtime v0.25.0 h1:XBnTc2Fi+w818jcJGzhiJKQuXl8479sZ4FhtV5hVJ1Q= +k8s.io/cli-runtime v0.25.0/go.mod h1:bHOI5ZZInRHhbq12OdUiYZQN8ml8aKZLwQgt9QlLINw= +k8s.io/client-go v0.21.3/go.mod h1:+VPhCgTsaFmGILxR/7E1N0S+ryO010QBeNCv5JwRGYU= +k8s.io/client-go v0.22.1/go.mod h1:BquC5A4UOo4qVDUtoc04/+Nxp1MeHcVc1HJm1KmG8kk= +k8s.io/client-go v0.25.0 h1:CVWIaCETLMBNiTUta3d5nzRbXvY5Hy9Dpl+VvREpu5E= +k8s.io/client-go v0.25.0/go.mod h1:lxykvypVfKilxhTklov0wz1FoaUZ8X4EwbhS6rpRfN8= +k8s.io/code-generator v0.21.3/go.mod h1:K3y0Bv9Cz2cOW2vXUrNZlFbflhuPvuadW6JdnN6gGKo= +k8s.io/code-generator v0.22.1/go.mod h1:eV77Y09IopzeXOJzndrDyCI88UBok2h6WxAlBwpxa+o= +k8s.io/component-base v0.21.3/go.mod h1:kkuhtfEHeZM6LkX0saqSK8PbdO7A0HigUngmhhrwfGQ= +k8s.io/component-base v0.22.1/go.mod h1:0D+Bl8rrnsPN9v0dyYvkqFfBeAd4u7n77ze+p8CMiPo= +k8s.io/component-base v0.25.0 h1:haVKlLkPCFZhkcqB6WCvpVxftrg6+FK5x1ZuaIDaQ5Y= +k8s.io/component-base v0.25.0/go.mod h1:F2Sumv9CnbBlqrpdf7rKZTmmd2meJq0HizeyY/yAFxk= +k8s.io/component-helpers v0.22.1/go.mod h1:QvBcDbX+qU5I2tMZABBF5fRwAlQwiv771IGBHK9WYh4= +k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/gengo v0.0.0-20201214224949-b6c5ce23f027/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= +k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/klog/v2 v2.8.0/go.mod h1:hy9LJ/NvuK+iVyP4Ehqva4HxZG/oXyIS3n3Jmire4Ec= +k8s.io/klog/v2 v2.9.0/go.mod h1:hy9LJ/NvuK+iVyP4Ehqva4HxZG/oXyIS3n3Jmire4Ec= +k8s.io/klog/v2 v2.80.0 h1:lyJt0TWMPaGoODa8B8bUuxgHS3W/m/bNr2cca3brA/g= +k8s.io/klog/v2 v2.80.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/kube-openapi v0.0.0-20210305001622-591a79e4bda7/go.mod h1:wXW5VT87nVfh/iLV8FpR2uDvrFyomxbtb1KivDbvPTE= +k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw= +k8s.io/kube-openapi v0.0.0-20220803162953-67bda5d908f1 h1:MQ8BAZPZlWk3S9K4a9NCkIFQtZShWqoha7snGixVgEA= +k8s.io/kube-openapi v0.0.0-20220803162953-67bda5d908f1/go.mod h1:C/N6wCaBHeBHkHUesQOQy2/MZqGgMAFPqGsGQLdbZBU= +k8s.io/kubectl v0.22.1/go.mod h1:mjAOgEbMNMtZWxnfM6jd+nPjPsaoLqO5xanc78WcSbw= +k8s.io/kubectl v0.25.0 h1:/Wn1cFqo8ik3iee1EvpxYre3bkWsGLXzLQI6uCCAkQc= +k8s.io/kubectl v0.25.0/go.mod h1:n16ULWsOl2jmQpzt2o7Dud1t4o0+Y186ICb4O+GwKAU= +k8s.io/metrics v0.22.1/go.mod h1:i/ZNap89UkV1gLa26dn7fhKAdheJaKy+moOqJbiif7E= +k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +k8s.io/utils v0.0.0-20210707171843-4b05e18ac7d9/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +k8s.io/utils v0.0.0-20220728103510-ee6ede2d64ed h1:jAne/RjBTyawwAy0utX5eqigAwz/lQhTmy+Hr/Cpue4= +k8s.io/utils v0.0.0-20220728103510-ee6ede2d64ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.19/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.22/go.mod h1:LEScyzhFmoF5pso/YSeBstl57mOzx9xlU9n85RGrDQg= +sigs.k8s.io/controller-runtime v0.10.0/go.mod h1:GCdh6kqV6IY4LK0JLwX0Zm6g233RtVGdb/f0+KSfprg= +sigs.k8s.io/controller-runtime v0.13.0 h1:iqa5RNciy7ADWnIc8QxCbOX5FEKVR3uxVxKHRMc2WIQ= +sigs.k8s.io/controller-runtime v0.13.0/go.mod h1:Zbz+el8Yg31jubvAEyglRZGdLAjplZl+PgtYNI6WNTI= +sigs.k8s.io/controller-tools v0.6.2/go.mod h1:oaeGpjXn6+ZSEIQkUe/+3I40PNiDYp9aeawbt3xTgJ8= +sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 h1:iXTIw73aPyC+oRdyqqvVJuloN1p0AC/kzH07hu3NE+k= +sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/kustomize/api v0.8.11/go.mod h1:a77Ls36JdfCWojpUqR6m60pdGY1AYFix4AH83nJtY1g= +sigs.k8s.io/kustomize/api v0.12.1 h1:7YM7gW3kYBwtKvoY216ZzY+8hM+lV53LUayghNRJ0vM= +sigs.k8s.io/kustomize/api v0.12.1/go.mod h1:y3JUhimkZkR6sbLNwfJHxvo1TCLwuwm14sCYnkH6S1s= +sigs.k8s.io/kustomize/cmd/config v0.9.13/go.mod h1:7547FLF8W/lTaDf0BDqFTbZxM9zqwEJqCKN9sSR0xSs= +sigs.k8s.io/kustomize/kustomize/v4 v4.2.0/go.mod h1:MOkR6fmhwG7hEDRXBYELTi5GSFcLwfqwzTRHW3kv5go= +sigs.k8s.io/kustomize/kyaml v0.11.0/go.mod h1:GNMwjim4Ypgp/MueD3zXHLRJEjz7RvtPae0AwlvEMFM= +sigs.k8s.io/kustomize/kyaml v0.13.9 h1:Qz53EAaFFANyNgyOEJbT/yoIHygK40/ZcvU3rgry2Tk= +sigs.k8s.io/kustomize/kyaml v0.13.9/go.mod h1:QsRbD0/KcU+wdk0/L0fIp2KLnohkVzs6fQ85/nOXac4= +sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/pkg/assets/assets.go b/pkg/assets/assets.go new file mode 100644 index 0000000..e58fb99 --- /dev/null +++ b/pkg/assets/assets.go @@ -0,0 +1,68 @@ +package assets + +import ( + "fmt" + "io/fs" + "os" + + "github.com/codeready-toolchain/toolchain-common/pkg/template" + "github.com/kubesaw/ksctl/pkg/configuration" + + templatev1 "github.com/openshift/api/template/v1" + "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/client-go/kubernetes/scheme" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// interface that matches all the methods provided by embed.FS +type FS interface { + fs.FS + fs.ReadDirFS + fs.ReadFileFS +} + +var decoder runtime.Decoder + +func init() { + decoder = serializer.NewCodecFactory(runtime.NewScheme()).UniversalDeserializer() +} + +type FilenameMatcher func(string) bool + +func GetSandboxEnvironmentConfig(sandboxConfigFile string) (*SandboxEnvironmentConfig, error) { + content, err := os.ReadFile(sandboxConfigFile) + if err != nil { + return nil, err + } + config := &SandboxEnvironmentConfig{} + if err := yaml.Unmarshal(content, config); err != nil { + return nil, err + } + return config, nil +} + +func GetRoles(f fs.ReadFileFS, clusterType configuration.ClusterType) ([]runtimeclient.Object, error) { + return GetSetupTemplateObjects(f, fmt.Sprintf("roles/%s.yaml", clusterType)) +} + +func GetSetupTemplateObjects(f fs.ReadFileFS, filePath string) ([]runtimeclient.Object, error) { + return ParseTemplate(f, fmt.Sprintf("setup/%s", filePath)) +} + +func ParseTemplate(f fs.ReadFileFS, fileName string) ([]runtimeclient.Object, error) { + content, err := f.ReadFile(fileName) + if err != nil { + return nil, err + } + + configTemplate := &templatev1.Template{} + _, _, err = decoder.Decode(content, nil, configTemplate) + if err != nil { + return nil, err + } + parameters := map[string]string{} + + return template.NewProcessor(scheme.Scheme).Process(configTemplate, parameters) +} diff --git a/pkg/assets/assets_test.go b/pkg/assets/assets_test.go new file mode 100644 index 0000000..e0b341a --- /dev/null +++ b/pkg/assets/assets_test.go @@ -0,0 +1,157 @@ +package assets_test + +import ( + "strings" + "testing" + + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + . "github.com/kubesaw/ksctl/pkg/test" + "github.com/kubesaw/ksctl/resources" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +const ( + hostRoles = `apiVersion: template.openshift.io/v1 +kind: Template +metadata: + name: host-roles +objects: +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: get-catalogsources + labels: + provider: sandbox-sre + rules: + - apiGroups: + - operators.coreos.com + resources: + - "catalogsources" + verbs: + - "get"` + + memberRoles = `apiVersion: template.openshift.io/v1 +kind: Template +metadata: + name: member-roles +objects: +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: get-deployments + labels: + provider: sandbox-sre + rules: + - apiGroups: + - apps + resources: + - deployments + verbs: + - "get"` +) + +func TestGetRoles(t *testing.T) { + // given + require.NoError(t, client.AddToScheme()) + files := NewFakeFiles(t, + FakeFile("setup/roles/host.yaml", []byte(hostRoles)), + FakeFile("setup/roles/member.yaml", []byte(memberRoles)), + ) + + for _, clusterType := range configuration.ClusterTypes { + + t.Run("get roles for cluster type "+clusterType.String(), func(t *testing.T) { + // when + objs, err := assets.GetRoles(files, clusterType) + + // then + require.NoError(t, err) + require.Len(t, objs, 1) + roleObject := objs[0] + + unstructuredRole, ok := roleObject.(*unstructured.Unstructured) + require.True(t, ok) + role := &rbacv1.Role{} + err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredRole.Object, role) + require.NoError(t, err) + assert.Empty(t, role.Namespace) + + if clusterType == configuration.Host { + assert.Equal(t, "get-catalogsources", role.Name) + } else { + assert.Equal(t, "get-deployments", role.Name) + } + }) + } +} + +func TestGetSandboxEnvironmentConfig(t *testing.T) { + // given + require.NoError(t, client.AddToScheme()) + + // when + sandboxEnvConfig, err := assets.GetSandboxEnvironmentConfig("../../test-resources/dummy.openshiftapps.com/sandbox-config.yaml") + + // then + require.NoError(t, err) + assert.NotEmpty(t, sandboxEnvConfig) + assert.NotEmpty(t, sandboxEnvConfig.Clusters.Host.API) + assert.NotEmpty(t, sandboxEnvConfig.Clusters.Members) + + for _, member := range sandboxEnvConfig.Clusters.Members { + assert.NotEmpty(t, member.Name) + assert.NotEmpty(t, member.API) + } + + assert.NotEmpty(t, sandboxEnvConfig.ServiceAccounts) + for _, sa := range sandboxEnvConfig.ServiceAccounts { + assert.NotEmpty(t, sa.Name) + verifyNamespacePermissions(t, sa.Name, sa.PermissionsPerClusterType) + } + + assert.NotEmpty(t, sandboxEnvConfig.Users) + for _, user := range sandboxEnvConfig.Users { + assert.NotEmpty(t, user.Name) + assert.NotEmpty(t, user.ID) + verifyNamespacePermissions(t, user.Name, user.PermissionsPerClusterType) + } +} + +func verifyNamespacePermissions(t *testing.T, entityName string, perClusterType assets.PermissionsPerClusterType) { + assert.NotEmpty(t, perClusterType) + for clusterType, permissions := range perClusterType { + if clusterType != configuration.Host.String() && clusterType != configuration.Member.String() { + assert.Failf(t, "not supported cluster type", "the cluster type '%s' should be either host or member", clusterType) + } + roles, err := assets.GetRoles(resources.Resources, configuration.ClusterType(clusterType)) + require.NoError(t, err) + var roleNames []string + for _, role := range roles { + roleNames = append(roleNames, role.GetName()) + } + + assert.NotEmpty(t, len(permissions.RoleBindings)+len(permissions.ClusterRoleBindings.ClusterRoles)) + for _, roleBindings := range permissions.RoleBindings { + assert.NotEmpty(t, roleBindings.Namespace) + if len(roleBindings.Roles) == 0 && len(roleBindings.ClusterRoles) == 0 { + assert.Failf(t, "missing permissions definitions", "there is not defined either a role nor a clusterRole for '%s': '%v'", entityName, roleBindings) + } + for _, role := range roleBindings.Roles { + if strings.Contains(role, "=") { + role = strings.Split(role, "=")[1] + } + assert.Contains(t, roleNames, role) + } + } + for _, clusterRole := range permissions.ClusterRoleBindings.ClusterRoles { + assert.NotEmpty(t, clusterRole) + } + } +} diff --git a/pkg/assets/sandbox_config.go b/pkg/assets/sandbox_config.go new file mode 100644 index 0000000..222886f --- /dev/null +++ b/pkg/assets/sandbox_config.go @@ -0,0 +1,51 @@ +package assets + +type SandboxEnvironmentConfig struct { + Clusters Clusters `yaml:"clusters"` + ServiceAccounts []ServiceAccount `yaml:"serviceAccounts"` + Users []User `yaml:"users"` +} + +type Clusters struct { + Host ClusterConfig `yaml:"host"` + Members []MemberCluster `yaml:"members"` +} + +type MemberCluster struct { + Name string `yaml:"name"` + ClusterConfig `yaml:",inline"` +} + +type ClusterConfig struct { + API string `yaml:"api"` +} + +type ServiceAccount struct { + Name string `yaml:"name"` + Namespace string `yaml:"namespace,omitempty"` + PermissionsPerClusterType `yaml:",inline"` +} + +type User struct { + Name string `yaml:"name"` + ID []string `yaml:"id"` + Groups []string `yaml:"groups"` + PermissionsPerClusterType `yaml:",inline"` +} + +type PermissionsPerClusterType map[string]PermissionBindings + +type PermissionBindings struct { + RoleBindings []RoleBindings `yaml:"roleBindings"` + ClusterRoleBindings ClusterRoleBindings `yaml:"clusterRoleBindings"` +} + +type RoleBindings struct { + Namespace string `yaml:"namespace"` + Roles []string `yaml:"roles,omitempty"` + ClusterRoles []string `yaml:"clusterRoles,omitempty"` +} + +type ClusterRoleBindings struct { + ClusterRoles []string `yaml:"clusterRoles,omitempty"` +} diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..3ea0386 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,377 @@ +package client + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "reflect" + "time" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + commonclient "github.com/codeready-toolchain/toolchain-common/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/ghodss/yaml" + configv1 "github.com/openshift/api/config/v1" + projectv1 "github.com/openshift/api/project/v1" + routev1 "github.com/openshift/api/route/v1" + templatev1 "github.com/openshift/api/template/v1" + userv1 "github.com/openshift/api/user/v1" + olmv1 "github.com/operator-framework/api/pkg/operators/v1" + olmv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" + errs "github.com/pkg/errors" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func AddToScheme() error { + addToSchemes := append(runtime.SchemeBuilder{}, + toolchainv1alpha1.AddToScheme, + olmv1.AddToScheme, + olmv1alpha1.AddToScheme, + rbacv1.AddToScheme, + routev1.Install, + userv1.Install, + projectv1.Install, + corev1.AddToScheme, + configv1.Install, + templatev1.Install) + return addToSchemes.AddToScheme(scheme.Scheme) +} + +var DefaultNewClient = NewClient + +func NewClient(token, apiEndpoint string) (runtimeclient.Client, error) { + if err := AddToScheme(); err != nil { + return nil, err + } + cfg, err := clientcmd.BuildConfigFromFlags(apiEndpoint, "") + if err != nil { + return nil, err + } + + cfg.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // nolint: gosec + }, + } + cfg.BearerToken = string(token) + cfg.QPS = 40.0 + cfg.Burst = 50 + cfg.Timeout = 60 * time.Second + + cl, err := runtimeclient.New(cfg, runtimeclient.Options{}) + if err != nil { + return nil, errs.Wrap(err, "cannot create client") + } + + return cl, nil +} + +var DefaultNewRESTClient = NewRESTClient + +func NewRESTClient(token, apiEndpoint string) (*rest.RESTClient, error) { + if err := AddToScheme(); err != nil { + return nil, err + } + config := &rest.Config{ + BearerToken: token, + Host: apiEndpoint, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // nolint: gosec + }, + }, + Timeout: 60 * time.Second, + // These fields need to be set when using the REST client ¯\_(ツ)_/¯ + ContentConfig: rest.ContentConfig{ + GroupVersion: &authv1.SchemeGroupVersion, + NegotiatedSerializer: scheme.Codecs, + }, + } + return rest.RESTClientFor(config) +} + +func PatchUserSignup(ctx *clicontext.CommandContext, name string, changeUserSignup func(*toolchainv1alpha1.UserSignup) (bool, error), afterMessage string) error { + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + userSignup, err := GetUserSignup(cl, cfg.SandboxNamespace, name) + if err != nil { + return err + } + patched := userSignup.DeepCopy() + if shouldUpdate, err := changeUserSignup(patched); !shouldUpdate || err != nil { + return err + } + if err := cl.Patch(context.TODO(), patched, runtimeclient.MergeFrom(userSignup)); err != nil { + return err + } + + ctx.Printlnf(afterMessage) + return nil +} + +func GetUserSignup(cl runtimeclient.Client, namespace, name string) (*toolchainv1alpha1.UserSignup, error) { + namespacedName := types.NamespacedName{ + Namespace: namespace, + Name: name, + } + userSignup := &toolchainv1alpha1.UserSignup{} + if err := cl.Get(context.TODO(), namespacedName, userSignup); err != nil { + return nil, err + } + return userSignup, nil +} + +func PatchMasterUserRecord(ctx *clicontext.CommandContext, name string, changeMasterUserRecord func(*toolchainv1alpha1.MasterUserRecord) (bool, error), afterMessage string) error { + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + mur, err := GetMasterUserRecord(cl, cfg.SandboxNamespace, name) + if err != nil { + return err + } + patched := mur.DeepCopy() + if shouldUpdate, err := changeMasterUserRecord(patched); !shouldUpdate || err != nil { + return err + } + if err := cl.Patch(context.TODO(), patched, runtimeclient.MergeFrom(mur)); err != nil { + return err + } + + ctx.Printlnf(afterMessage) + return nil +} + +func GetMasterUserRecord(cl runtimeclient.Client, namespace, name string) (*toolchainv1alpha1.MasterUserRecord, error) { + namespacedName := types.NamespacedName{ + Namespace: namespace, + Name: name, + } + obj := &toolchainv1alpha1.MasterUserRecord{} + if err := cl.Get(context.TODO(), namespacedName, obj); err != nil { + return nil, err + } + return obj, nil +} + +func PatchSpace(ctx *clicontext.CommandContext, name string, changeSpace func(*toolchainv1alpha1.Space) (bool, error), afterMessage string) error { + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + space, err := GetSpace(cl, cfg.SandboxNamespace, name) + if err != nil { + return err + } + patched := space.DeepCopy() + if shouldUpdate, err := changeSpace(patched); !shouldUpdate || err != nil { + return err + } + if err := cl.Patch(context.TODO(), patched, runtimeclient.MergeFrom(space)); err != nil { + return err + } + + ctx.Printlnf(afterMessage) + return nil +} + +func GetSpace(cl runtimeclient.Client, namespace, name string) (*toolchainv1alpha1.Space, error) { + namespacedName := types.NamespacedName{ + Namespace: namespace, + Name: name, + } + obj := &toolchainv1alpha1.Space{} + if err := cl.Get(context.TODO(), namespacedName, obj); err != nil { + return nil, err + } + return obj, nil +} + +type SpaceBindingMatchingLabel func(runtimeclient.MatchingLabels) + +func ForSpace(spaceName string) SpaceBindingMatchingLabel { + return func(labels runtimeclient.MatchingLabels) { + labels[toolchainv1alpha1.SpaceBindingSpaceLabelKey] = spaceName + } +} + +func ForMasterUserRecord(murName string) SpaceBindingMatchingLabel { + return func(labels runtimeclient.MatchingLabels) { + labels[toolchainv1alpha1.SpaceBindingMasterUserRecordLabelKey] = murName + } +} + +func ListSpaceBindings(cl runtimeclient.Client, namespace string, opts ...SpaceBindingMatchingLabel) ([]toolchainv1alpha1.SpaceBinding, error) { + spacebindings := &toolchainv1alpha1.SpaceBindingList{} + matchingLabels := runtimeclient.MatchingLabels{} + for _, apply := range opts { + apply(matchingLabels) + } + if err := cl.List(context.TODO(), spacebindings, runtimeclient.InNamespace(namespace), matchingLabels); err != nil { + return nil, err + } + return spacebindings.Items, nil +} + +func GetNSTemplateTier(cfg configuration.ClusterConfig, cl runtimeclient.Client, name string) (*toolchainv1alpha1.NSTemplateTier, error) { + namespacedName := types.NamespacedName{ + Namespace: cfg.SandboxNamespace, + Name: name, + } + obj := &toolchainv1alpha1.NSTemplateTier{} + if err := cl.Get(context.TODO(), namespacedName, obj); err != nil { + return nil, err + } + return obj, nil +} + +func GetUserTier(cfg configuration.ClusterConfig, cl runtimeclient.Client, name string) (*toolchainv1alpha1.UserTier, error) { + namespacedName := types.NamespacedName{ + Namespace: cfg.SandboxNamespace, + Name: name, + } + obj := &toolchainv1alpha1.UserTier{} + if err := cl.Get(context.TODO(), namespacedName, obj); err != nil { + return nil, err + } + return obj, nil +} + +// Ensure creates or updates the given object and returns if the object was either created or updated (which means +// that no error occurred and the administrator confirmed execution of the action) +func Ensure(term ioutils.Terminal, cl runtimeclient.Client, obj runtimeclient.Object) (bool, error) { + namespacedName := types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + } + return ensure(term, cl, namespacedName, obj) +} + +func ensure(term ioutils.Terminal, cl runtimeclient.Client, namespacedName types.NamespacedName, obj runtimeclient.Object) (bool, error) { + content, err := yaml.Marshal(obj) + if err != nil { + return false, err + } + resourceKind := obj.GetObjectKind().GroupVersionKind().Kind + // if GVK is not defined in the given object, then let's use its type + if resourceKind == "" { + resourceKind = reflect.TypeOf(obj).Elem().Name() + } + term.PrintContextSeparatorWithBodyf(string(content), "Using %s resource:", resourceKind) + + existing := obj.DeepCopyObject().(runtimeclient.Object) + if err := cl.Get(context.TODO(), namespacedName, existing); err != nil && !apierrors.IsNotFound(err) { + return false, err + } else if err == nil { + term.Printlnf("There is an already existing %s with the same name: %s", resourceKind, namespacedName) + if !term.AskForConfirmation(ioutils.WithDangerZoneMessagef( + "update of the already created "+resourceKind, "update the %s with the hard-coded version?", resourceKind)) { + return false, nil + } + metaNew, err := meta.Accessor(obj) + if err != nil { + return false, errs.Wrapf(err, "cannot get metadata from %+v", obj) + } + metaExisting, err := meta.Accessor(existing) + if err != nil { + return false, errs.Wrapf(err, "cannot get metadata from %+v", existing) + } + metaNew.SetResourceVersion(metaExisting.GetResourceVersion()) + // make sure that when updating a 'service' object, we retain its existing the `spec.clusterIP` field, + // otherwise we get the following error: `Service "prometheus" is invalid: spec.clusterIP: Invalid value: "": field is immutable` + if err := commonclient.RetainClusterIP(obj, existing); err != nil { + return false, err + } + + if err := cl.Update(context.TODO(), obj); err != nil { + return false, err + } + term.Printlnf("\nThe '%s' %s has been updated", namespacedName.Name, resourceKind) + return true, nil + } + + if !term.AskForConfirmation(ioutils.WithMessagef("create the %s resource with the name %s ?", resourceKind, namespacedName)) { + return false, nil + } + if err := cl.Create(context.TODO(), obj); err != nil { + return false, err + } + term.Printlnf("\nThe '%s' %s has been created", namespacedName, resourceKind) + return true, nil +} + +// Create creates the resource only if it does not exist yet (ie, if a resource of the same kind in the same namespace/name doesn't exist yet) +func Create(term ioutils.Terminal, cl runtimeclient.Client, obj runtimeclient.Object) error { + namespacedName := types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + } + objCopy := obj.DeepCopyObject().(runtimeclient.Object) + if err := cl.Get(context.TODO(), namespacedName, objCopy); err != nil && !apierrors.IsNotFound(err) { + return err + } else if apierrors.IsNotFound(err) { + if err := cl.Create(context.TODO(), obj); err != nil { + return err + } + term.Printlnf("\nThe '%s' %s has been created", namespacedName, reflect.TypeOf(obj).Elem().Name()) + return nil + } + term.Printlnf("\nThe '%s' %s already exists", namespacedName, reflect.TypeOf(obj).Elem().Name()) + return nil +} + +// GetRouteURL return the scheme+host of the route with the given namespaced name. +// Since routes may take a bit of time to be available, this func uses a wait loop +// to make sure that the route was created, or fails after a timeout. +func GetRouteURL(term ioutils.Terminal, cl runtimeclient.Client, namespacedName types.NamespacedName) (string, error) { + term.Printlnf("Waiting for '%s' route to be available...", namespacedName.Name) + route := routev1.Route{} + if err := wait.Poll(retryInterval, timeout, func() (done bool, err error) { + if err := cl.Get(context.TODO(), namespacedName, &route); err != nil && !apierrors.IsNotFound(err) { + return false, err + } + if len(route.Status.Ingress) == 0 { + return false, nil // route is not ready yet + } + return true, nil + }); err != nil { + return "", errs.Wrapf(err, "unable to get route to %s", namespacedName) + } + scheme := "https" + if route.Spec.TLS == nil || *route.Spec.TLS == (routev1.TLSConfig{}) { + scheme = "http" + } + return fmt.Sprintf("%s://%s/%s", scheme, route.Spec.Host, route.Spec.Path), nil +} + +var timeout = 5 * time.Second +var retryInterval = 200 * time.Millisecond diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..d52b347 --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,583 @@ +package client_test + +import ( + "context" + "fmt" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/states" + commontest "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/client" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/h2non/gock" + routev1 "github.com/openshift/api/route/v1" + olmv1 "github.com/operator-framework/api/pkg/operators/v1" + olmv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestNewClientOK(t *testing.T) { + // given + t.Cleanup(gock.OffAll) + gock.New("https://example.com"). + Get("api"). + Persist(). + Reply(200). + BodyString("{}") + + // when + cl, err := client.NewClient("cool-token", "https://example.com") + + // then + require.NoError(t, err) + assert.NotNil(t, cl) +} + +func TestNewClientFail(t *testing.T) { + // when + cl, err := client.NewClient("cool-token", "https://fail-cluster.com") + + // then + require.Error(t, err) + assert.Nil(t, cl) +} + +func TestPatchUserSignup(t *testing.T) { + // given + SetFileConfig(t, Host()) + + t.Run("update is successful", func(t *testing.T) { + //given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := client.PatchUserSignup(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup) (bool, error) { + states.SetApprovedManually(signup, true) + return true, nil + }, "updated") + + // then + require.NoError(t, err) + states.SetApprovedManually(userSignup, true) + AssertUserSignupSpec(t, fakeClient, userSignup) + }) + + t.Run("UserSignup should not be updated", func(t *testing.T) { + //given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := client.PatchUserSignup(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup) (bool, error) { + states.SetApprovedManually(signup, true) + return false, nil + }, "updated") + + // then + require.NoError(t, err) + AssertUserSignupSpec(t, fakeClient, userSignup) + }) + + t.Run("change UserSignup func returns error", func(t *testing.T) { + //given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := client.PatchUserSignup(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup) (bool, error) { + states.SetApprovedManually(signup, true) + return false, fmt.Errorf("some error") + }, "updated") + + // then + require.EqualError(t, err, "some error") + AssertUserSignupSpec(t, fakeClient, userSignup) + }) + + t.Run("get of UserSignup fails", func(t *testing.T) { + //given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + fakeClient.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("some error") + } + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := client.PatchUserSignup(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup) (bool, error) { + states.SetApprovedManually(signup, true) + return true, nil + }, "updated") + + // then + require.EqualError(t, err, "some error") + }) + + t.Run("update of UserSignup fails", func(t *testing.T) { + //given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + fakeClient.MockPatch = func(ctx context.Context, obj runtimeclient.Object, patch runtimeclient.Patch, opts ...runtimeclient.PatchOption) error { + return fmt.Errorf("some error") + } + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := client.PatchUserSignup(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup) (bool, error) { + states.SetApprovedManually(signup, true) + return true, nil + }, "updated") + + // then + require.EqualError(t, err, "some error") + AssertUserSignupSpec(t, fakeClient, userSignup) + }) + + t.Run("client creation fails", func(t *testing.T) { + //given + userSignup := NewUserSignup() + fakeClient := commontest.NewFakeClient(t, userSignup) + term := NewFakeTerminal() + newClient := func(_, _ string) (runtimeclient.Client, error) { + return nil, fmt.Errorf("some error") + } + newRESTClient := client.DefaultNewRESTClient + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := client.PatchUserSignup(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup) (bool, error) { + states.SetApprovedManually(signup, true) + return true, nil + }, "updated") + + // then + require.EqualError(t, err, "some error") + AssertUserSignupSpec(t, fakeClient, userSignup) + }) +} + +func TestUpdateUserSignupLacksPermissions(t *testing.T) { + // given + SetFileConfig(t, Host(NoToken())) + + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := client.PatchUserSignup(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup) (bool, error) { + states.SetApprovedManually(signup, true) + return true, nil + }, "updated") + + // then + require.EqualError(t, err, "sandbox command failed: the token in your sandbox.yaml file is missing") + AssertUserSignupSpec(t, fakeClient, userSignup) +} + +func TestEnsure(t *testing.T) { + // given + require.NoError(t, client.AddToScheme()) + subs := newSubscription("cool-op", "staging") + + t.Run("successful", func(t *testing.T) { + + t.Run("when creating", func(t *testing.T) { + // given + fakeClient := commontest.NewFakeClient(t) + term := NewFakeTerminalWithResponse("Y") + actual := subs.DeepCopy() + + // when + applied, err := client.Ensure(term, fakeClient, actual) + + // then + require.NoError(t, err) + assert.True(t, applied) + namespacedName := commontest.NamespacedName(subs.Namespace, subs.Name) + AssertSubscriptionHasSpec(t, fakeClient, namespacedName, subs.Spec) + output := term.Output() + assert.NotContains(t, output, "!!! DANGER ZONE !!!") + assert.Contains(t, output, "Are you sure that you want to create the Subscription resource with the name toolchain-host-operator/cool-subs ?") + assert.Contains(t, output, "The 'toolchain-host-operator/cool-subs' Subscription has been created") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when updating", func(t *testing.T) { + // given + fakeClient := commontest.NewFakeClient(t, newSubscription("other-operator", "prod")) + term := NewFakeTerminalWithResponse("Y") + + // when + actual := subs.DeepCopy() + applied, err := client.Ensure(term, fakeClient, actual) + + // then + require.NoError(t, err) + assert.True(t, applied) + namespacedName := commontest.NamespacedName(subs.Namespace, subs.Name) + AssertSubscriptionHasSpec(t, fakeClient, namespacedName, subs.Spec) + output := term.Output() + assert.Contains(t, output, "!!! DANGER ZONE !!!") + assert.Contains(t, output, "Are you sure that you want to update the Subscription with the hard-coded version?") + assert.Contains(t, output, "The 'cool-subs' Subscription has been updated") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when N is answered", func(t *testing.T) { + // given + existing := newSubscription("other-operator", "prod") + fakeClient := commontest.NewFakeClient(t, existing) + term := NewFakeTerminalWithResponse("N") + + // when + actual := subs.DeepCopy() + applied, err := client.Ensure(term, fakeClient, actual) + + // then + require.NoError(t, err) + assert.False(t, applied) + namespacedName := commontest.NamespacedName(subs.Namespace, subs.Name) + AssertSubscriptionHasSpec(t, fakeClient, namespacedName, existing.Spec) + output := term.Output() + assert.Contains(t, output, "!!! DANGER ZONE !!!") + assert.Contains(t, output, "Are you sure that you want to update the Subscription with the hard-coded version?") + assert.NotContains(t, output, "The 'cool-subs' Subscription has been updated") + assert.NotContains(t, output, "cool-token") + }) + }) + + t.Run("failed", func(t *testing.T) { + + t.Run("when get fails", func(t *testing.T) { + // given + existing := newSubscription("other-operator", "prod") + fakeClient := commontest.NewFakeClient(t, newSubscription("other-operator", "prod")) + fakeClient.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("some error") + } + term := NewFakeTerminalWithResponse("Y") + + // when + actual := subs.DeepCopy() + applied, err := client.Ensure(term, fakeClient, actual) + + // then + require.Error(t, err) + assert.False(t, applied) + fakeClient.MockGet = nil + namespacedName := commontest.NamespacedName(subs.Namespace, subs.Name) + AssertSubscriptionHasSpec(t, fakeClient, namespacedName, existing.Spec) + output := term.Output() + assert.NotContains(t, output, "!!! DANGER ZONE !!!") + assert.NotContains(t, output, "Are you sure that you want to update the Subscription with the hard-coded version?") + assert.NotContains(t, output, "The 'cool-subs' Subscription has been updated") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when create fails", func(t *testing.T) { + // given + fakeClient := commontest.NewFakeClient(t) + fakeClient.MockCreate = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.CreateOption) error { + return fmt.Errorf("some error") + } + term := NewFakeTerminalWithResponse("Y") + + // when + actual := subs.DeepCopy() + applied, err := client.Ensure(term, fakeClient, actual) + + // then + require.Error(t, err) + assert.False(t, applied) + namespacedName := commontest.NamespacedName(subs.Namespace, subs.Name) + AssertSubscriptionDoesNotExist(t, fakeClient, namespacedName) + output := term.Output() + assert.NotContains(t, output, "!!! DANGER ZONE !!!") + assert.Contains(t, output, "Are you sure that you want to create the Subscription resource with the name toolchain-host-operator/cool-subs ?") + assert.NotContains(t, output, "The 'toolchain-host-operator/cool-subs' Subscription has been created") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when update fails", func(t *testing.T) { + // given + existing := newSubscription("other-operator", "prod") + fakeClient := commontest.NewFakeClient(t, newSubscription("other-operator", "prod")) + fakeClient.MockUpdate = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.UpdateOption) error { + return fmt.Errorf("some error") + } + term := NewFakeTerminalWithResponse("Y") + + // when + actual := subs.DeepCopy() + applied, err := client.Ensure(term, fakeClient, actual) + + // then + require.Error(t, err) + assert.False(t, applied) + namespacedName := commontest.NamespacedName(subs.Namespace, subs.Name) + AssertSubscriptionHasSpec(t, fakeClient, namespacedName, existing.Spec) + output := term.Output() + assert.Contains(t, output, "!!! DANGER ZONE !!!") + assert.Contains(t, output, "Are you sure that you want to update the Subscription with the hard-coded version?") + assert.NotContains(t, output, "The 'cool-subs' Subscription has been updated") + assert.NotContains(t, output, "cool-token") + }) + }) +} + +func TestCreate(t *testing.T) { + + t.Run("create", func(t *testing.T) { + + t.Run("if it does not exist yet", func(t *testing.T) { + // given + namespacedName := commontest.NamespacedName("openshift-customer-monitoring", "openshift-customer-monitoring") + fakeClient := commontest.NewFakeClient(t) + term := NewFakeTerminalWithResponse("Y") + operatorGroup := newOperatorGroup(namespacedName, map[string]string{"provider": "sandbox-sre"}) + + // when + err := client.Create(term, fakeClient, operatorGroup) + + // then + require.NoError(t, err) + AssertOperatorGroupHasLabels(t, fakeClient, namespacedName, map[string]string{"provider": "sandbox-sre"}) + output := term.Output() + assert.Contains(t, output, "The 'openshift-customer-monitoring/openshift-customer-monitoring' OperatorGroup has been created") + }) + }) + + t.Run("do not create", func(t *testing.T) { + + t.Run("if it already exists", func(t *testing.T) { + // given + namespacedName := commontest.NamespacedName("openshift-customer-monitoring", "openshift-customer-monitoring") + fakeClient := commontest.NewFakeClient(t, newOperatorGroup(namespacedName, map[string]string{"provider": "osd"})) + term := NewFakeTerminalWithResponse("Y") + operatorGroup := newOperatorGroup(namespacedName, map[string]string{"provider": "sandbox-sre"}) + + // when + err := client.Create(term, fakeClient, operatorGroup) + + // then + require.NoError(t, err) + AssertOperatorGroupHasLabels(t, fakeClient, namespacedName, map[string]string{"provider": "osd"}) + output := term.Output() + assert.Contains(t, output, "The 'openshift-customer-monitoring/openshift-customer-monitoring' OperatorGroup already exists") + }) + + t.Run("when error occurs on client.Get", func(t *testing.T) { + // given + fakeClient := commontest.NewFakeClient(t, newSubscription("other-operator", "prod")) + fakeClient.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("get failed") + } + term := NewFakeTerminalWithResponse("Y") + namespacedName := commontest.NamespacedName("openshift-customer-monitoring", "openshift-customer-monitoring") + operatorGroup := newOperatorGroup(namespacedName, map[string]string{"provider": "sandbox-sre"}) + + // when + err := client.Create(term, fakeClient, operatorGroup) + + // then + require.Error(t, err) + require.EqualError(t, err, "get failed") + }) + + t.Run("when error occurs on client.Create", func(t *testing.T) { + // given + fakeClient := commontest.NewFakeClient(t, newSubscription("other-operator", "prod")) + fakeClient.MockCreate = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.CreateOption) error { + return fmt.Errorf("create failed") + } + term := NewFakeTerminalWithResponse("Y") + namespacedName := commontest.NamespacedName("openshift-customer-monitoring", "openshift-customer-monitoring") + operatorGroup := newOperatorGroup(namespacedName, map[string]string{"provider": "sandbox-sre"}) + + // when + err := client.Create(term, fakeClient, operatorGroup) + + // then + require.Error(t, err) + require.EqualError(t, err, "create failed") + }) + }) +} + +func TestGetRoute(t *testing.T) { + + // given + require.NoError(t, client.AddToScheme()) + term := NewFakeTerminalWithResponse("Y") + + t.Run("success", func(t *testing.T) { + + t.Run("route with TLS enabled", func(t *testing.T) { + // given + route := &routev1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "openshift-monitoring", + Name: "thanos-querier", + }, + Spec: routev1.RouteSpec{ + Host: "prometheus-dev", + Path: "graph", + TLS: &routev1.TLSConfig{ + Termination: routev1.TLSTerminationReencrypt, + }, + }, + Status: routev1.RouteStatus{ + Ingress: []routev1.RouteIngress{ + { + Host: "prometheus-dev/graph", + }, + }, + }, + } + fakeClient := commontest.NewFakeClient(t, route) + + // when + r, err := client.GetRouteURL(term, fakeClient, types.NamespacedName{ + Namespace: "openshift-monitoring", + Name: "thanos-querier", + }) + // then + require.NoError(t, err) + assert.Equal(t, "https://prometheus-dev/graph", r) + }) + + t.Run("route with TLS not enabled", func(t *testing.T) { + // given + route := &routev1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "openshift-monitoring", + Name: "thanos-querier", + }, + Spec: routev1.RouteSpec{ + Host: "prometheus-dev", + Path: "graph", + }, + Status: routev1.RouteStatus{ + Ingress: []routev1.RouteIngress{ + { + Host: "prometheus-dev/graph", + }, + }, + }, + } + fakeClient := commontest.NewFakeClient(t, route) + + // when + r, err := client.GetRouteURL(term, fakeClient, types.NamespacedName{ + Namespace: "openshift-monitoring", + Name: "thanos-querier", + }) + // then + require.NoError(t, err) + assert.Equal(t, "http://prometheus-dev/graph", r) + }) + }) + + t.Run("failures", func(t *testing.T) { + + t.Run("client error", func(t *testing.T) { + // given + route := &routev1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "openshift-monitoring", + Name: "thanos-querier", + }, + Spec: routev1.RouteSpec{}, + // no status will cause a timeout + } + fakeClient := commontest.NewFakeClient(t, route) + fakeClient.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("mock error") + } + // when + _, err := client.GetRouteURL(term, fakeClient, types.NamespacedName{ + Namespace: "openshift-monitoring", + Name: "thanos-querier", + }) + // then + require.Error(t, err) + require.EqualError(t, err, "unable to get route to openshift-monitoring/thanos-querier: mock error") + + }) + + t.Run("timeout", func(t *testing.T) { + // given + route := &routev1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "openshift-monitoring", + Name: "thanos-querier", + }, + Spec: routev1.RouteSpec{}, + // no status will cause a timeout + } + fakeClient := commontest.NewFakeClient(t, route) + + // when + _, err := client.GetRouteURL(term, fakeClient, types.NamespacedName{ + Namespace: "openshift-monitoring", + Name: "thanos-querier", + }) + // then + require.Error(t, err) + require.EqualError(t, err, "unable to get route to openshift-monitoring/thanos-querier: timed out waiting for the condition") + }) + }) +} + +func newSubscription(pkg, channel string) *olmv1alpha1.Subscription { + return &olmv1alpha1.Subscription{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operators.coreos.com/v1alpha1", + Kind: "Subscription", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: commontest.HostOperatorNs, + Name: "cool-subs", + }, + Spec: &olmv1alpha1.SubscriptionSpec{ + Channel: channel, + InstallPlanApproval: olmv1alpha1.ApprovalAutomatic, + Package: pkg, + CatalogSource: "cool-subs", + CatalogSourceNamespace: commontest.HostOperatorNs, + }, + } +} + +func newOperatorGroup(namespacedName types.NamespacedName, labels map[string]string) *olmv1.OperatorGroup { + return &olmv1.OperatorGroup{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespacedName.Namespace, + Name: namespacedName.Name, + Labels: labels, + }, + Spec: olmv1.OperatorGroupSpec{ + TargetNamespaces: []string{ + namespacedName.Namespace, + }, + }, + } +} diff --git a/pkg/client/command_creator.go b/pkg/client/command_creator.go new file mode 100644 index 0000000..2a7dfc1 --- /dev/null +++ b/pkg/client/command_creator.go @@ -0,0 +1,5 @@ +package client + +import "os/exec" + +type CommandCreator func(name string, arg ...string) *exec.Cmd diff --git a/pkg/client/sandbox_config_file.go b/pkg/client/sandbox_config_file.go new file mode 100644 index 0000000..8d2e324 --- /dev/null +++ b/pkg/client/sandbox_config_file.go @@ -0,0 +1,33 @@ +package client + +import ( + "os" + "path" +) + +func EnsureSandboxCliConfigFile() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dirPath := path.Join(home, ".kube") + if _, err := os.Stat(dirPath); os.IsNotExist(err) { + if err := os.Mkdir(dirPath, os.ModePerm); err != nil { + return "", err + } + } + filePath := path.Join(dirPath, "sandbox-cli-config") + _, err = os.Stat(filePath) + if os.IsNotExist(err) { + emptyFile, err := os.Create(filePath) + if err != nil { + return "", err + } + if err := emptyFile.Close(); err != nil { + return "", err + } + } else if err != nil { + return "", err + } + return filePath, nil +} diff --git a/pkg/cmd/add_space_users.go b/pkg/cmd/add_space_users.go new file mode 100644 index 0000000..6827d07 --- /dev/null +++ b/pkg/cmd/add_space_users.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/spacebinding" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/cmd/flags" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" +) + +func NewAddSpaceUsersCmd() *cobra.Command { + var spaceName string + var role string + var users []string + command := &cobra.Command{ + Use: "add-space-users --space= --role= --users=", + Short: "Create SpaceBinding(s) between the given Space and the given MasterUserRecord(s)", + Long: `Create SpaceBinding(s) between the given Space and the given MasterUserRecord(s). The first parameter is the name of the Space followed by the role to be assigned to the user and +one or more users specified by their MasterUserRecord name. One SpaceBinding will be created for each user.`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + + return AddSpaceUsers(ctx, spaceName, role, users) + }, + } + command.Flags().StringVarP(&spaceName, "space", "s", "", "the name of the space to add users to") + flags.MustMarkRequired(command, "space") + command.Flags().StringVarP(&role, "role", "r", "", "the name of the role to assign to the users") + flags.MustMarkRequired(command, "role") + command.Flags().StringSliceVarP(&users, "users", "u", []string{}, "the masteruserrecord names of the users to add to the space delimited by comma") + flags.MustMarkRequired(command, "users") + + return command +} + +func AddSpaceUsers(ctx *clicontext.CommandContext, spaceName, role string, usersToAdd []string) error { + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + + // get Space + ctx.Println("Checking space...") + space, err := client.GetSpace(cl, cfg.SandboxNamespace, spaceName) + if err != nil { + return err + } + + nsTemplTierName := space.Spec.TierName + nsTemplTier, err := client.GetNSTemplateTier(cfg, cl, nsTemplTierName) + if err != nil { + return err + } + + // check if role is within the allowed spaceroles from the NSTemplateTier + isRoleValid := false + validRolesMsg := strings.Builder{} + validRolesMsg.WriteString("the following are valid roles:\n") + + for actual := range nsTemplTier.Spec.SpaceRoles { + validRolesMsg.WriteString(fmt.Sprintf("%s\n", actual)) + if role == actual { + isRoleValid = true + } + } + if !isRoleValid { + return fmt.Errorf("invalid role '%s' for space '%s' - %s", role, spaceName, validRolesMsg.String()) + } + + // get MasterUserRecords + ctx.Println("Checking users...") + spaceBindingsToCreate := []*toolchainv1alpha1.SpaceBinding{} + for _, murName := range usersToAdd { + mur, err := client.GetMasterUserRecord(cl, cfg.SandboxNamespace, murName) + if err != nil { + return err + } + spaceBindingsToCreate = append(spaceBindingsToCreate, spacebinding.NewSpaceBinding(mur, space, space.Labels[toolchainv1alpha1.SpaceCreatorLabelKey], spacebinding.WithRole(role))) + } + + // confirmation before SpaceBinding creation + if err := ctx.PrintObject(space, "Targeted Space"); err != nil { + return err + } + confirmation := ctx.AskForConfirmation(ioutils.WithMessagef( + "add users to the above Space?")) + if !confirmation { + return nil + } + + ctx.Println("Creating SpaceBinding(s)...") + // create SpaceBindings + for _, sb := range spaceBindingsToCreate { + if err := cl.Create(context.TODO(), sb); err != nil { + return err + } + } + + ctx.Printlnf("\nSpaceBinding(s) successfully created") + return nil +} diff --git a/pkg/cmd/add_space_users_test.go b/pkg/cmd/add_space_users_test.go new file mode 100644 index 0000000..1b62d7d --- /dev/null +++ b/pkg/cmd/add_space_users_test.go @@ -0,0 +1,250 @@ +package cmd_test + +import ( + "context" + "fmt" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/codeready-toolchain/toolchain-common/pkg/test/masteruserrecord" + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + uuid "github.com/satori/go.uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestAddSpaceUsers(t *testing.T) { + + t.Run("when answer is Y", func(t *testing.T) { + // given + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := initAddSpaceUsersTest(t, mur1, mur2) + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.AddSpaceUsers(ctx, "testspace", "admin", []string{"alice", "bob"}) + + // then + require.NoError(t, err) + output := term.Output() + assertSpaceBindings(t, fakeClient, []string{"alice", "bob"}, "admin") + assert.Contains(t, output, "Are you sure that you want to add users to the above Space?") + assert.Contains(t, output, "SpaceBinding(s) successfully created") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when a non-default role is specified", func(t *testing.T) { + // given + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := initAddSpaceUsersTest(t, mur1, mur2) + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.AddSpaceUsers(ctx, "testspace", "viewer", []string{"alice", "bob"}) + + // then + require.NoError(t, err) + output := term.Output() + assertSpaceBindings(t, fakeClient, []string{"alice", "bob"}, "viewer") + assert.Contains(t, output, "Are you sure that you want to add users to the above Space?") + assert.Contains(t, output, "SpaceBinding(s) successfully created") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when answer is N", func(t *testing.T) { + // given + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := initAddSpaceUsersTest(t, mur1, mur2) + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("N") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.AddSpaceUsers(ctx, "testspace", "admin", []string{"alice", "bob"}) + + // then + require.NoError(t, err) + output := term.Output() + assertSpaceBindings(t, fakeClient, []string{}, "") // no spacebindings expected + assert.Contains(t, output, "Are you sure that you want to add users to the above Space?") + assert.NotContains(t, output, "SpaceBinding(s) successfully created") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when space not found", func(t *testing.T) { + // given + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, mur1, mur2) // no space + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("N") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.AddSpaceUsers(ctx, "testspace", "admin", []string{"alice", "bob"}) + + // then + require.EqualError(t, err, `spaces.toolchain.dev.openshift.com "testspace" not found`) + output := term.Output() + assertSpaceBindings(t, fakeClient, []string{}, "") // no spacebindings expected + assert.NotContains(t, output, "Are you sure that you want to add users to the above Space?") + assert.NotContains(t, output, "SpaceBinding(s) successfully created") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when first mur not found", func(t *testing.T) { + // given + newClient, newRESTClient, fakeClient := initAddSpaceUsersTest(t) // no murs + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("N") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.AddSpaceUsers(ctx, "testspace", "admin", []string{"alice", "bob"}) + + // then + require.EqualError(t, err, `masteruserrecords.toolchain.dev.openshift.com "alice" not found`) + output := term.Output() + assertSpaceBindings(t, fakeClient, []string{}, "") // no spacebindings expected + assert.NotContains(t, output, "Are you sure that you want to add users to the above Space?") + assert.NotContains(t, output, "SpaceBinding(s) successfully created") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when second mur not found", func(t *testing.T) { + // given + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := initAddSpaceUsersTest(t, mur1) // mur2 missing + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("N") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.AddSpaceUsers(ctx, "testspace", "admin", []string{"alice", "bob"}) + + // then + require.EqualError(t, err, `masteruserrecords.toolchain.dev.openshift.com "bob" not found`) + output := term.Output() + assertSpaceBindings(t, fakeClient, []string{}, "") // no spacebindings expected + assert.NotContains(t, output, "Are you sure that you want to add users to the above Space?") + assert.NotContains(t, output, "SpaceBinding(s) successfully created") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when role is invalid", func(t *testing.T) { + // given + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := initAddSpaceUsersTest(t, mur1) // mur2 missing + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("N") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.AddSpaceUsers(ctx, "testspace", "badrole", []string{"alice", "bob"}) // invalid role + + // then + require.Contains(t, err.Error(), "invalid role 'badrole' for space 'testspace' - the following are valid roles:") + require.Contains(t, err.Error(), "\nadmin\n") + require.Contains(t, err.Error(), "\nviewer\n") + output := term.Output() + assertSpaceBindings(t, fakeClient, []string{}, "") // no spacebindings expected + assert.NotContains(t, output, "Are you sure that you want to add users to the above Space?") + assert.NotContains(t, output, "SpaceBinding(s) successfully created") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("client get error", func(t *testing.T) { + // given + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := initAddSpaceUsersTest(t, mur1, mur2) + fakeClient.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("client error") + } + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.AddSpaceUsers(ctx, "testspace", "admin", []string{"alice", "bob"}) + + // then + require.EqualError(t, err, "client error") + output := term.Output() + assertSpaceBindings(t, fakeClient, []string{}, "") // no spacebindings expected + assert.NotContains(t, output, "Are you sure that you want to add users to the above Space?") + assert.NotContains(t, output, "SpaceBinding(s) successfully created") + assert.NotContains(t, output, "cool-token") + }) +} + +func initAddSpaceUsersTest(t *testing.T, murs ...*toolchainv1alpha1.MasterUserRecord) (clicontext.NewClientFunc, clicontext.NewRESTClientFunc, *test.FakeClient) { + space := newSpace() + nsTemplateTier := newNSTemplateTier("base") + roles := make(map[string]toolchainv1alpha1.NSTemplateTierSpaceRole) + roles["admin"] = toolchainv1alpha1.NSTemplateTierSpaceRole{ + TemplateRef: uuid.NewV4().String(), + } + roles["viewer"] = toolchainv1alpha1.NSTemplateTierSpaceRole{ + TemplateRef: uuid.NewV4().String(), + } + nsTemplateTier.Spec.SpaceRoles = roles + objs := []runtime.Object{space, nsTemplateTier} + for _, mur := range murs { + objs = append(objs, mur) + } + newClient, newRESTClient, fakeClient := NewFakeClients(t, objs...) + return newClient, newRESTClient, fakeClient +} + +func assertSpaceBindings(t *testing.T, fakeClient *test.FakeClient, expectedMurs []string, expectedRole string) { + + // list all SpaceBindings for the given space + allSpaceBindings := &toolchainv1alpha1.SpaceBindingList{} + err := fakeClient.List(context.TODO(), allSpaceBindings, runtimeclient.InNamespace(test.HostOperatorNs), runtimeclient.MatchingLabels{ + toolchainv1alpha1.SpaceBindingSpaceLabelKey: "testspace", + }) + require.NoError(t, err) + + // verify the expected number of SpaceBindings were created + assert.Len(t, allSpaceBindings.Items, len(expectedMurs)) + + // check that all expected MURs have SpaceBindings + var checked int + for _, expectedMur := range expectedMurs { + for _, sb := range allSpaceBindings.Items { + if sb.Labels[toolchainv1alpha1.SpaceBindingMasterUserRecordLabelKey] == expectedMur { + require.Equal(t, "testcreator", sb.Labels[toolchainv1alpha1.SpaceCreatorLabelKey]) + require.Equal(t, expectedMur, sb.Labels[toolchainv1alpha1.SpaceBindingMasterUserRecordLabelKey]) + require.Equal(t, expectedMur, sb.Spec.MasterUserRecord) + require.Equal(t, "testspace", sb.Spec.Space) + require.Equal(t, expectedRole, sb.Spec.SpaceRole) + checked++ + } + } + } + if checked != len(expectedMurs) { + require.Fail(t, "some expected murs were not found") + } +} diff --git a/pkg/cmd/adm/adm.go b/pkg/cmd/adm/adm.go new file mode 100644 index 0000000..19eefb5 --- /dev/null +++ b/pkg/cmd/adm/adm.go @@ -0,0 +1,32 @@ +package adm + +import ( + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" +) + +func NewAdmCmd() *cobra.Command { + admCommand := &cobra.Command{ + Use: "adm", + Short: "Administrative Commands", + Long: `Actions for administering Dev Sandbox instance.`, + } + registerCommands(admCommand) + + admCommand.PersistentFlags().BoolVarP(&ioutils.AssumeYes, "assume-yes", "y", false, "Automatically answer yes for all questions.") + + return admCommand +} + +func registerCommands(admCommand *cobra.Command) { + // commands with go runtime client + admCommand.AddCommand(NewRestartCmd()) + admCommand.AddCommand(NewSetupCmd()) + admCommand.AddCommand(NewGenerateCliConfigsCmd()) + admCommand.AddCommand(NewUnregisterMemberCmd()) + admCommand.AddCommand(NewMustGatherNamespaceCmd()) + + // commands running external script + admCommand.AddCommand(NewRegisterMemberCmd()) +} diff --git a/pkg/cmd/adm/generate_cli_configs.go b/pkg/cmd/adm/generate_cli_configs.go new file mode 100644 index 0000000..8827538 --- /dev/null +++ b/pkg/cmd/adm/generate_cli_configs.go @@ -0,0 +1,271 @@ +package adm + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/cmd/flags" + "github.com/kubesaw/ksctl/pkg/configuration" + "github.com/kubesaw/ksctl/pkg/ioutils" + "github.com/kubesaw/ksctl/pkg/utils" + errs "github.com/pkg/errors" + "github.com/spf13/cobra" + "gopkg.in/yaml.v2" + authv1 "k8s.io/api/authentication/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/homedir" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/utils/pointer" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +type generateFlags struct { + sandboxConfigFile, outDir string + dev bool + kubeconfigs []string +} + +func NewGenerateCliConfigsCmd() *cobra.Command { + f := generateFlags{} + command := &cobra.Command{ + Use: "generate-cli-configs --sandbox-config=", + Short: "Generate sandbox.yaml files", + Long: `Generate sandbox.yaml files, that is used by sandbox-cli, for every ServiceAccount defined in the given sandbox-config.yaml file`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, _ []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + return generate(term, f, runtimeclient.New, DefaultNewExternalClientFromConfig) + }, + } + command.Flags().StringVarP(&f.sandboxConfigFile, "sandbox-config", "c", "", "Use the given sandbox config file") + flags.MustMarkRequired(command, "sandbox-config") + command.Flags().BoolVarP(&f.dev, "dev", "d", false, "If running in a dev cluster") + + configDirPath := fmt.Sprintf("%s/src/github.com/kubesaw/ksctl/out/config", os.Getenv("GOPATH")) + command.Flags().StringVarP(&f.outDir, "out-dir", "o", configDirPath, "Directory where generated sandbox.yaml files should be stored") + + defaultKubeconfigPath := "" + if home := homedir.HomeDir(); home != "" { + defaultKubeconfigPath = filepath.Join(home, ".kube", "config") + } + command.Flags().StringSliceVarP(&f.kubeconfigs, "kubeconfig", "k", []string{defaultKubeconfigPath}, "Kubeconfig(s) for managing multiple clusters and the access to them - paths should be comma separated when using multiple of them. "+ + "In dev mode, the first one has to represent the host cluster.") + + return command +} + +type NewRESTClientFromConfigFunc func(config *rest.Config) (*rest.RESTClient, error) + +type NewClientFromConfigFunc func(config *rest.Config, options runtimeclient.Options) (runtimeclient.Client, error) + +var DefaultNewExternalClientFromConfig = func(config *rest.Config) (*rest.RESTClient, error) { + if config.GroupVersion == nil { + config.GroupVersion = &authv1.SchemeGroupVersion + } + if config.NegotiatedSerializer == nil { + config.NegotiatedSerializer = scheme.Codecs + } + return rest.RESTClientFor(config) +} + +func generate(term ioutils.Terminal, flags generateFlags, newClient NewClientFromConfigFunc, newExternalClient NewRESTClientFromConfigFunc) error { + if err := client.AddToScheme(); err != nil { + return err + } + + // Get the unmarshalled version of sandbox-config.yaml + sandboxEnvConfig, err := assets.GetSandboxEnvironmentConfig(flags.sandboxConfigFile) + if err != nil { + return errs.Wrapf(err, "unable get sandbox-config.yaml file from %s", flags.sandboxConfigFile) + } + + ctx := &generateContext{ + Terminal: term, + newClient: newClient, + newRESTClient: newExternalClient, + sandboxEnvConfig: sandboxEnvConfig, + kubeconfigPaths: flags.kubeconfigs, + } + + // sandboxUserConfigsPerName contains all sandboxUserConfig objects that will be marshalled to sandbox.yaml files + sandboxUserConfigsPerName := map[string]configuration.SandboxUserConfig{} + + // use host API either from the sandbox-config.yaml or from kubeconfig if --dev flag was used + hostSpec := sandboxEnvConfig.Clusters.Host + if flags.dev { + term.Printlnf("Using kubeconfig located at '%s' for retrieving the host cluster information...", flags.kubeconfigs[0]) + kubeconfig, err := clientcmd.BuildConfigFromFlags("", flags.kubeconfigs[0]) + if err != nil { + return errs.Wrapf(err, "unable to build kubeconfig") + } + hostSpec.API = kubeconfig.Host + } + + // firstly generate for the host cluster + if err := generateForCluster(ctx, configuration.Host, "host", hostSpec, sandboxUserConfigsPerName); err != nil { + return err + } + + // and then based on the data from sandbox-config.yaml files generate also all members + for _, member := range sandboxEnvConfig.Clusters.Members { + + // use either the member API from sandbox-config.yaml file or use the same as API as for host if --dev flag was used + memberSpec := member.ClusterConfig + if flags.dev { + memberSpec.API = hostSpec.API + } + + if err := generateForCluster(ctx, configuration.Member, member.Name, memberSpec, sandboxUserConfigsPerName); err != nil { + return err + } + } + + return writeSandboxUserConfigs(term, flags.outDir, sandboxUserConfigsPerName) +} + +func serverName(API string) string { + return strings.Split(strings.Split(API, "api.")[1], ":")[0] +} + +// writeSandboxUserConfigs marshals the given SandboxUserConfig objects and stored them in sandbox-sre/out/config// directories +func writeSandboxUserConfigs(term ioutils.Terminal, configDirPath string, sandboxUserConfigsPerName map[string]configuration.SandboxUserConfig) error { + if err := os.RemoveAll(configDirPath); err != nil { + return err + } + for name, sandboxUserConfig := range sandboxUserConfigsPerName { + pathDir := fmt.Sprintf("%s/%s", configDirPath, name) + if err := os.MkdirAll(pathDir, 0744); err != nil { + return err + } + content, err := yaml.Marshal(sandboxUserConfig) + if err != nil { + return err + } + path := pathDir + "/sandbox.yaml" + if err := os.WriteFile(path, content, 0600); err != nil { + return err + } + term.Printlnf("sandbox.yaml file for %s was stored in %s", name, path) + } + return nil +} + +type generateContext struct { + ioutils.Terminal + newClient NewClientFromConfigFunc + newRESTClient NewRESTClientFromConfigFunc + sandboxEnvConfig *assets.SandboxEnvironmentConfig + kubeconfigPaths []string +} + +// contains tokens mapped by SA name +type tokenPerSA map[string]string + +func generateForCluster(ctx *generateContext, clusterType configuration.ClusterType, clusterName string, clusterSpec assets.ClusterConfig, sandboxUserConfigsPerName map[string]configuration.SandboxUserConfig) error { + ctx.PrintContextSeparatorf("Generating the content of the sandbox.yaml files for %s cluster running at %s", clusterName, clusterSpec.API) + + // find config we can build client for the cluster from + externalClient, err := buildClientFromKubeconfigFiles(ctx, clusterSpec.API, ctx.kubeconfigPaths) + if err != nil { + return err + } + + clusterDef := configuration.ClusterDefinition{ + ClusterType: clusterType, + ServerName: serverName(clusterSpec.API), + ServerAPI: clusterSpec.API, + } + + tokenPerSAName := tokenPerSA{} + + for _, sa := range ctx.sandboxEnvConfig.ServiceAccounts { + for saClusterType := range sa.PermissionsPerClusterType { + if saClusterType != clusterType.String() { + continue + } + saNamespace := sandboxSRENamespace(clusterType) + if sa.Namespace != "" { + saNamespace = sa.Namespace + } + ctx.Printlnf("Getting token for SA '%s' in namespace '%s'", sa.Name, saNamespace) + token, err := getServiceAccountToken(externalClient, types.NamespacedName{ + Namespace: saNamespace, + Name: sa.Name}) + if token == "" || err != nil { + return err + } + tokenPerSAName[sa.Name] = token + } + } + + addToSandboxUserConfigs(clusterDef, clusterName, sandboxUserConfigsPerName, tokenPerSAName) + + return nil +} + +// buildClientFromKubeconfigFiles goes through the list of kubeconfigs and tries to build the runtimeclient.Client & rest.RESTClient. +// As soon as the build is successful, then it returns the built instances. If the build fails for all of the kubeconfig files, then it returns an error. +func buildClientFromKubeconfigFiles(ctx *generateContext, API string, kubeconfigPaths []string) (*rest.RESTClient, error) { + for _, kubeconfigPath := range kubeconfigPaths { + kubeconfig, err := clientcmd.BuildConfigFromFlags(API, kubeconfigPath) + if err != nil { + ctx.Printlnf("Unable to build config from kubeconfig file located at '%s' for the cluster '%s': %s", kubeconfigPath, API, err.Error()) + ctx.Printlnf("trying next one...") + continue + } + externalCl, err := ctx.newRESTClient(kubeconfig) + if err != nil { + ctx.Printlnf("Unable to build config from kubeconfig file located at '%s' for the cluster '%s': %s", kubeconfigPath, API, err.Error()) + ctx.Printlnf("trying next one...") + continue + } + ctx.Printlnf("Using kubeconfig file located at '%s' for the cluster '%s'", kubeconfigPath, API) + return externalCl, nil + } + return nil, fmt.Errorf("could not setup client from any of the provided kubeconfig files for the '%s' cluster", API) +} + +// getServiceAccountToken returns the SA's token or returns an error if none was found. +// NOTE: due to a changes in OpenShift 4.11, tokens are not listed as `secrets` in ServiceAccounts. +// The recommended solution is to use the TokenRequest API when server version >= 4.11 +// (see https://docs.openshift.com/container-platform/4.11/release_notes/ocp-4-11-release-notes.html#ocp-4-11-notable-technical-changes) +func getServiceAccountToken(cl *rest.RESTClient, namespacedName types.NamespacedName) (string, error) { + tokenRequest := &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + ExpirationSeconds: pointer.Int64(int64(365 * 24 * 60 * 60)), // token will be valid for 1 year + }, + } + result := &authv1.TokenRequest{} + if err := cl.Post(). + AbsPath(fmt.Sprintf("api/v1/namespaces/%s/serviceaccounts/%s/token", namespacedName.Namespace, namespacedName.Name)). + Body(tokenRequest). + Do(context.TODO()). + Into(result); err != nil { + return "", err + } + return result.Status.Token, nil +} + +// addToSandboxUserConfigs adds to sandboxUserConfig objects information about the cluster as well as the SA token +func addToSandboxUserConfigs(clusterDev configuration.ClusterDefinition, clusterName string, sandboxUserConfigsPerName map[string]configuration.SandboxUserConfig, tokensPerSA tokenPerSA) { + for name, token := range tokensPerSA { + if _, ok := sandboxUserConfigsPerName[name]; !ok { + sandboxUserConfigsPerName[name] = configuration.SandboxUserConfig{ + Name: name, + ClusterAccessDefinitions: map[string]configuration.ClusterAccessDefinition{}, + } + } + clusterName := utils.KebabToCamelCase(clusterName) + sandboxUserConfigsPerName[name].ClusterAccessDefinitions[clusterName] = configuration.ClusterAccessDefinition{ + ClusterDefinition: clusterDev, + Token: token, + } + } +} diff --git a/pkg/cmd/adm/generate_cli_configs_test.go b/pkg/cmd/adm/generate_cli_configs_test.go new file mode 100644 index 0000000..98b7744 --- /dev/null +++ b/pkg/cmd/adm/generate_cli_configs_test.go @@ -0,0 +1,368 @@ +package adm + +import ( + "encoding/json" + "fmt" + "os" + "path" + "testing" + + commontest "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + . "github.com/kubesaw/ksctl/pkg/test" + routev1 "github.com/openshift/api/route/v1" + "github.com/stretchr/testify/assert" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/rest" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/h2non/gock" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestGenerateCliConfigs(t *testing.T) { + // given + require.NoError(t, client.AddToScheme()) + sandboxEnvConfig := NewSandboxEnvironmentConfig( + Clusters(HostServerAPI). + AddMember("member1", Member1ServerAPI). + AddMember("member2", Member2ServerAPI), + ServiceAccounts( + Sa("john", "", + HostRoleBindings("toolchain-host-operator", Role("install-operator"), ClusterRole("admin")), + MemberRoleBindings("toolchain-member-operator", Role("install-operator"), ClusterRole("admin"))), + Sa("bob", "", + HostRoleBindings("toolchain-host-operator", Role("restart=restart-deployment"), ClusterRole("restart=edit")), + MemberRoleBindings("toolchain-member-operator", Role("restart=restart-deployment"), ClusterRole("restart=edit")))), + Users()) + + sandboxEnvConfigContent, err := yaml.Marshal(sandboxEnvConfig) + require.NoError(t, err) + kubeconfigFiles := createKubeconfigFiles(t, sandboxKubeconfigContent, sandboxKubeconfigContentMember2) + + setupGockForServiceAccounts(t, HostServerAPI, + newServiceAccount("sandbox-sre-host", "john"), + newServiceAccount("sandbox-sre-host", "bob"), + ) + setupGockForServiceAccounts(t, Member1ServerAPI, + newServiceAccount("sandbox-sre-member", "john"), + newServiceAccount("sandbox-sre-member", "bob"), + ) + setupGockForServiceAccounts(t, Member2ServerAPI, + newServiceAccount("sandbox-sre-member", "john"), + newServiceAccount("sandbox-sre-member", "bob"), + ) + t.Cleanup(gock.OffAll) + + configFile := createSandboxConfigFile(t, "sandbox.host.openshiftapps.com", sandboxEnvConfigContent) + + _, newClient, newExternalClient := newFakeClientFuncs(t, sandboxEnvConfig.Clusters) + term := NewFakeTerminalWithResponse("Y") + term.Tee(os.Stdout) + + t.Run("successful", func(t *testing.T) { + t.Run("when there is host and two members", func(t *testing.T) { + // given + tempDir, err := os.MkdirTemp("", "sandbox-sre-out-") + require.NoError(t, err) + flags := generateFlags{kubeconfigs: kubeconfigFiles, sandboxConfigFile: configFile, outDir: tempDir} + + // when + err = generate(term, flags, newClient, newExternalClient) + + // then + require.NoError(t, err) + + verifySandboxUserConfigFiles(t, tempDir, hasHost(), hasMember("member1", "member1"), hasMember("member2", "member2")) + }) + + t.Run("when there SAs are defined for host cluster only", func(t *testing.T) { + // given + saInHostOnly := NewSandboxEnvironmentConfig( + Clusters(HostServerAPI). + AddMember("member1", Member1ServerAPI). + AddMember("member2", Member2ServerAPI), + ServiceAccounts( + Sa("john", "", + HostRoleBindings("toolchain-host-operator", Role("install-operator"), ClusterRole("admin"))), + Sa("bob", "", + HostRoleBindings("toolchain-host-operator", Role("restart=restart-deployment"), ClusterRole("restart=edit")))), + Users()) + sandboxEnvConfigContent, err := yaml.Marshal(saInHostOnly) + require.NoError(t, err) + configFile := createSandboxConfigFile(t, "sandbox.host.openshiftapps.com", sandboxEnvConfigContent) + tempDir, err := os.MkdirTemp("", "sandbox-sre-out-") + require.NoError(t, err) + flags := generateFlags{kubeconfigs: kubeconfigFiles, sandboxConfigFile: configFile, outDir: tempDir} + + // when + err = generate(term, flags, newClient, newExternalClient) + + // then + require.NoError(t, err) + + verifySandboxUserConfigFiles(t, tempDir, hasHost()) + }) + + t.Run("in dev mode", func(t *testing.T) { + // given + setupGockForServiceAccounts(t, HostServerAPI, + newServiceAccount("sandbox-sre-member", "john"), + newServiceAccount("sandbox-sre-member", "bob"), + ) + tempDir, err := os.MkdirTemp("", "sandbox-sre-out-") + require.NoError(t, err) + kubeconfigFiles := createKubeconfigFiles(t, sandboxKubeconfigContent) + flags := generateFlags{kubeconfigs: kubeconfigFiles, sandboxConfigFile: configFile, outDir: tempDir, dev: true} + + // when + err = generate(term, flags, newClient, newExternalClient) + + // then + require.NoError(t, err) + + verifySandboxUserConfigFiles(t, tempDir, hasHost(), hasMember("member1", "host"), hasMember("member2", "host")) + }) + }) + + t.Run("failed", func(t *testing.T) { + t.Run("test buildClientFromKubeconfigFiles cannot build REST client", func(t *testing.T) { + // given + ctx := &generateContext{ + Terminal: NewFakeTerminalWithResponse("y"), + newClient: func(config *rest.Config, options runtimeclient.Options) (runtimeclient.Client, error) { + return commontest.NewFakeClient(t), nil + }, + newRESTClient: func(config *rest.Config) (*rest.RESTClient, error) { + return nil, fmt.Errorf("some error") + }, + } + + // when + _, err := buildClientFromKubeconfigFiles(ctx, "https://dummy.openshift.com", kubeconfigFiles) + + // then + require.Error(t, err) + require.ErrorContains(t, err, "could not setup client from any of the provided kubeconfig files") + }) + + t.Run("wrong sandbox-config.yaml file path", func(t *testing.T) { + // given + tempDir, err := os.MkdirTemp("", "sandbox-sre-out-") + require.NoError(t, err) + flags := generateFlags{kubeconfigs: kubeconfigFiles, sandboxConfigFile: "does/not/exist", outDir: tempDir} + + // when + err = generate(term, flags, newClient, newExternalClient) + + // then + require.Error(t, err) + require.ErrorContains(t, err, "unable get sandbox-config.yaml file from does/not/exist") + }) + + t.Run("wrong kubeconfig file path", func(t *testing.T) { + // given + tempDir, err := os.MkdirTemp("", "sandbox-sre-out-") + require.NoError(t, err) + flags := generateFlags{kubeconfigs: []string{"does/not/exist"}, sandboxConfigFile: configFile, outDir: tempDir} + + // when + err = generate(term, flags, newClient, newExternalClient) + + // then + require.Error(t, err) + require.ErrorContains(t, err, "could not setup client from any of the provided kubeconfig files") + }) + + t.Run("when token call is not mocked for SA", func(t *testing.T) { + // given + saInHostOnly := NewSandboxEnvironmentConfig( + Clusters(HostServerAPI), + ServiceAccounts( + Sa("notmocked", "", + HostRoleBindings("toolchain-host-operator", Role("install-operator"), ClusterRole("admin")))), + Users()) + sandboxEnvConfigContent, err := yaml.Marshal(saInHostOnly) + require.NoError(t, err) + configFile := createSandboxConfigFile(t, "sandbox.host.openshiftapps.com", sandboxEnvConfigContent) + tempDir, err := os.MkdirTemp("", "sandbox-sre-out-") + require.NoError(t, err) + flags := generateFlags{kubeconfigs: kubeconfigFiles, sandboxConfigFile: configFile, outDir: tempDir} + + // when + err = generate(term, flags, newClient, newExternalClient) + + // then + require.ErrorContains(t, err, "notmocked/token\": gock: cannot match any request") + }) + }) +} + +func TestGetServiceAccountToken(t *testing.T) { + + // given + require.NoError(t, client.AddToScheme()) + + setupGockForServiceAccounts(t, "https://api.example.com", newServiceAccount("openshift-customer-monitoring", "loki")) + t.Cleanup(gock.OffAll) + cl, err := client.NewRESTClient("secret_token", "https://api.example.com") + cl.Client.Transport = gock.DefaultTransport // make sure that the underlying client's request are intercepted by Gock + // gock.Observe(gock.DumpRequest) + require.NoError(t, err) + // when + actualToken, err := getServiceAccountToken(cl, types.NamespacedName{ + Namespace: "openshift-customer-monitoring", + Name: "loki", + }) + + // then + require.NoError(t, err) + assert.Equal(t, "token-secret-for-loki", actualToken) // `token-secret-for-loki` is the answered mock by Gock in `setupGockForServiceAccounts(...)` +} + +func verifySandboxUserConfigFiles(t *testing.T, tempDir string, clusterAssertions ...userConfigClusterAssertions) { + tempDirInfo, err := os.ReadDir(tempDir) + require.NoError(t, err) + assert.Len(t, tempDirInfo, 2) + for _, userDir := range tempDirInfo { + require.True(t, userDir.IsDir()) + userDirInfo, err := os.ReadDir(path.Join(tempDir, userDir.Name())) + require.NoError(t, err) + + assert.Len(t, userDirInfo, 1) + assert.Equal(t, "sandbox.yaml", userDirInfo[0].Name()) + content, err := os.ReadFile(path.Join(tempDir, userDir.Name(), userDirInfo[0].Name())) + require.NoError(t, err) + + sandboxUserconfig := configuration.SandboxUserConfig{} + err = yaml.Unmarshal(content, &sandboxUserconfig) + require.NoError(t, err) + + userConfig := assertSandboxUserConfig(t, sandboxUserconfig, userDir.Name()). + hasNumberOfClusters(len(clusterAssertions)) + for _, applyAssertion := range clusterAssertions { + applyAssertion(t, userDir.Name(), userConfig) + } + } +} + +type userConfigClusterAssertions func(*testing.T, string, *sandboxUserConfigAssertion) + +func hasHost() userConfigClusterAssertions { + return func(t *testing.T, name string, assertion *sandboxUserConfigAssertion) { + assertion.hasCluster("host", "host", configuration.Host) + } +} + +func hasMember(memberName, subDomain string) userConfigClusterAssertions { + return func(t *testing.T, name string, assertion *sandboxUserConfigAssertion) { + assertion.hasCluster(memberName, subDomain, configuration.Member) + } +} + +// SandboxUserConfig assertions + +type sandboxUserConfigAssertion struct { + t *testing.T + sandboxUserConfig configuration.SandboxUserConfig + saBaseName string +} + +func assertSandboxUserConfig(t *testing.T, sandboxUserConfig configuration.SandboxUserConfig, saBaseName string) *sandboxUserConfigAssertion { + require.NotNil(t, sandboxUserConfig) + assert.Equal(t, saBaseName, sandboxUserConfig.Name) + return &sandboxUserConfigAssertion{ + t: t, + sandboxUserConfig: sandboxUserConfig, + saBaseName: saBaseName, + } +} + +func (a *sandboxUserConfigAssertion) hasNumberOfClusters(number int) *sandboxUserConfigAssertion { + require.Len(a.t, a.sandboxUserConfig.ClusterAccessDefinitions, number) + return a +} + +func (a *sandboxUserConfigAssertion) hasCluster(clusterName, subDomain string, clusterType configuration.ClusterType) { + require.NotNil(a.t, a.sandboxUserConfig.ClusterAccessDefinitions[clusterName]) + + assert.NotNil(a.t, a.sandboxUserConfig.ClusterAccessDefinitions[clusterName]) + assert.Equal(a.t, clusterType, a.sandboxUserConfig.ClusterAccessDefinitions[clusterName].ClusterType) + assert.Equal(a.t, fmt.Sprintf("sandbox.%s.openshiftapps.com", subDomain), a.sandboxUserConfig.ClusterAccessDefinitions[clusterName].ServerName) + assert.Equal(a.t, fmt.Sprintf("https://api.sandbox.%s.openshiftapps.com:6443", subDomain), a.sandboxUserConfig.ClusterAccessDefinitions[clusterName].ServerAPI) + + assert.Equal(a.t, fmt.Sprintf("token-secret-for-%s", a.saBaseName), a.sandboxUserConfig.ClusterAccessDefinitions[clusterName].Token) +} + +func setupGockForServiceAccounts(t *testing.T, apiEndpoint string, sas ...*corev1.ServiceAccount) { + for _, sa := range sas { + expectedToken := "token-secret-for-" + sa.Name + resultTokenRequest := &authv1.TokenRequest{ + Status: authv1.TokenRequestStatus{ + Token: expectedToken, + }, + } + resultTokenRequestStr, err := json.Marshal(resultTokenRequest) + require.NoError(t, err) + path := fmt.Sprintf("api/v1/namespaces/%s/serviceaccounts/%s/token", sa.Namespace, sa.Name) + t.Logf("mocking access to POST %s/%s", apiEndpoint, path) + gock.New(apiEndpoint). + Post(path). + Persist(). + Reply(200). + BodyString(string(resultTokenRequestStr)) + } +} + +func newFakeClientFuncs(t *testing.T, clusters assets.Clusters) (map[string]*commontest.FakeClient, NewClientFromConfigFunc, NewRESTClientFromConfigFunc) { + fakeClientsPerName := map[string]*commontest.FakeClient{} + fakeClientsPerHost := map[string]*commontest.FakeClient{} + + addClient := func(clusterName, host string) { + consoleRoute := &routev1.Route{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "openshift-console", + Name: "console", + }, + Spec: routev1.RouteSpec{ + Host: fmt.Sprintf("console-openshift-console.sandbox.%s.openshift.com", clusterName), + Port: &routev1.RoutePort{ + TargetPort: intstr.FromString("https"), + }, + }, + } + fakeClient := commontest.NewFakeClient(t, consoleRoute) + fakeClientsPerName[clusterName] = fakeClient + fakeClientsPerHost[host] = fakeClient + } + if clusters.Host.API != "" { + addClient("host", clusters.Host.API) + } + for _, member := range clusters.Members { + addClient(member.Name, member.API) + } + + return fakeClientsPerName, + func(config *rest.Config, options runtimeclient.Options) (runtimeclient.Client, error) { + return fakeClientsPerHost[config.Host], nil + }, + func(config *rest.Config) (*rest.RESTClient, error) { + return DefaultNewExternalClientFromConfig(config) + } +} + +func newServiceAccount(namespace, name string) *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + } +} diff --git a/pkg/cmd/adm/must_gather_namespace.go b/pkg/cmd/adm/must_gather_namespace.go new file mode 100644 index 0000000..abf2baf --- /dev/null +++ b/pkg/cmd/adm/must_gather_namespace.go @@ -0,0 +1,206 @@ +package adm + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/kubesaw/ksctl/pkg/cmd/flags" + "github.com/kubesaw/ksctl/pkg/ioutils" + "gopkg.in/yaml.v3" + + "github.com/spf13/cobra" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + 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/runtime/serializer" + "k8s.io/client-go/discovery" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/homedir" + "k8s.io/kubectl/pkg/scheme" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewMustGatherNamespaceCmd() *cobra.Command { + var destDir string + var kubeconfig string + cmd := &cobra.Command{ + Use: "must-gather-namespace --kubeconfig --dest-dir ", + Short: "Dump all resources from a namespace", + Long: "Dump all resources from a namespace into the destination directory, one resource per file", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + kubeconfig, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + kubeconfig.Timeout = 60 * time.Second + // These fields need to be set when using the REST client ¯\_(ツ)_/¯ + kubeconfig.ContentConfig = restclient.ContentConfig{ + GroupVersion: &authv1.SchemeGroupVersion, + NegotiatedSerializer: scheme.Codecs, + } + if err != nil { + return err + } + return MustGatherNamespace(term, kubeconfig, args[0], destDir) + }, + } + defaultKubeconfigPath := "" + if home := homedir.HomeDir(); home != "" { + defaultKubeconfigPath = filepath.Join(home, ".kube", "config") + } + cmd.Flags().StringVar(&destDir, "dest-dir", "", "Gather information with a specific local folder to copy to") + flags.MustMarkRequired(cmd, "dest-dir") + cmd.Flags().StringVar(&kubeconfig, "kubeconfig", defaultKubeconfigPath, "Path to the kubeconfig file (default: "+defaultKubeconfigPath+")") + flags.MustMarkRequired(cmd, "kubeconfig") + return cmd +} + +func MustGatherNamespace(term ioutils.Terminal, kubeconfig *restclient.Config, namespace, destDir string) error { + // verify that the destDir exists, otherwise, create it + + // If path is already a directory, MkdirAll does nothing and returns nil. + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + entries, err := os.ReadDir(destDir) + if err != nil { + return err + } + if len(entries) > 0 { + term.Printlnf("The '%s' dest-dir is not empty. Aborting.", destDir) + return nil + } + + // find the API for the given resource type + rcl, err := restclient.RESTClientFor(kubeconfig) + if err != nil { + return err + } + dcl := discovery.NewDiscoveryClient(rcl) + if err != nil { + return err + } + + term.Println("fetching the list of API resources on the cluster...") + apiResourceLists, err := dcl.ServerPreferredNamespacedResources() + if err != nil { + return err + } + + term.Printlnf("gathering all resources from the '%s' namespace...", namespace) + cl, err := runtimeclient.New(kubeconfig, runtimeclient.Options{}) + if err != nil { + return err + } + + decoder := serializer.NewCodecFactory(runtime.NewScheme()).UniversalDeserializer() + + for _, apiResourceList := range apiResourceLists { + gv, err := schema.ParseGroupVersion(apiResourceList.GroupVersion) + if err != nil { + return err + } + for _, r := range apiResourceList.APIResources { + if !r.Namespaced { + continue + } + if !can(r.Verbs, "list") { + continue + } + // we don't need to collect these `PackageManifest` resources, most of them are + // cluster-wide manifests. + // See https://olm.operatorframework.io/docs/tasks/list-operators-available-to-install/#using-the-packagemanifest-api + if gv.Group == "packages.operators.coreos.com" && r.Kind == "PackageManifest" { + // let's skip this kind of resources... + continue + } + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gv.Group, + Version: gv.Version, + Kind: r.Kind, + }) + if err := cl.List(context.Background(), list, runtimeclient.InNamespace(namespace)); err != nil { + // log the error but continue so we can collect the remaining resources + term.Printlnf("failed to list %s/%s: %v", strings.ToLower(gv.String()), strings.ToLower(r.Kind), err) + continue + } + for _, item := range list.Items { + term.Printlnf("found %s/%s", item.GetKind(), item.GetName()) + filename := filepath.Join(destDir, fmt.Sprintf("%s-%s.yaml", strings.ToLower(item.GetKind()), item.GetName())) + data, err := yaml.Marshal(item.Object) + if err != nil { + term.Printlnf("failed to marshal %s/%s %s: %v", strings.ToLower(gv.String()), strings.ToLower(r.Kind), item.GetName(), err) + continue + } + if err := writeToFile(filename, data); err != nil { + term.Printlnf("failed to save contents of %s/%s %s: %v", strings.ToLower(gv.String()), strings.ToLower(r.Kind), item.GetName(), err) + return err + } + // also, for pods, gather process names and logs from all containers + if item.GetAPIVersion() == "v1" && item.GetKind() == "Pod" { + pod := &corev1.Pod{} + if _, _, err := decoder.Decode(data, nil, pod); err != nil { + term.Printlnf("failed to decode %s/%s '%s': %v", strings.ToLower(gv.String()), strings.ToLower(r.Kind), item.GetName(), err) + continue + } + for _, cs := range pod.Status.ContainerStatuses { + if cs.Started != nil && *cs.Started { + if err := gatherContainerLogs(term, rcl, destDir, namespace, pod.Name, cs.Name); err != nil { + term.Printlnf("failed to collect logs from container '%s' in pod '%s': %v", cs.Name, pod.Name, err) + // ignore error, continue to next container + continue + } + } + } + } + } + } + } + return nil +} + +func gatherContainerLogs(term ioutils.Terminal, rcl *restclient.RESTClient, destDir, namespace, podName, containerName string) error { + term.Printlnf("collecting logs from %s/%s", podName, containerName) + p := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/log", namespace, podName) + result := rcl.Get().AbsPath(p).Param("container", containerName).Do(context.Background()) + if err := result.Error(); err != nil { + return err + } + filename := filepath.Join(destDir, fmt.Sprintf("pod-%s-%s.logs", podName, containerName)) + data, err := result.Raw() + if err != nil { + return err + } + if err := writeToFile(filename, data); err != nil { + return err + } + return nil +} + +func writeToFile(filename string, data []byte) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + _, err = fmt.Fprint(f, string(data)) + return err +} + +func can(verbs metav1.Verbs, verb string) bool { + for _, v := range verbs { + if v == verb { + return true + } + } + return false +} diff --git a/pkg/cmd/adm/must_gather_namespace_test.go b/pkg/cmd/adm/must_gather_namespace_test.go new file mode 100644 index 0000000..06ea19c --- /dev/null +++ b/pkg/cmd/adm/must_gather_namespace_test.go @@ -0,0 +1,371 @@ +package adm_test + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/h2non/gock" + "github.com/kubesaw/ksctl/pkg/cmd/adm" + . "github.com/kubesaw/ksctl/pkg/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + authv1 "k8s.io/api/authentication/v1" + restclient "k8s.io/client-go/rest" + "k8s.io/kubectl/pkg/scheme" +) + +func TestMustGatherNamespaceCmd(t *testing.T) { + + // given + t.Cleanup(gock.OffAll) + apiServerURL := "https://api.example.com" + newAPIServer(apiServerURL) + kubeconfig := &restclient.Config{ + Host: apiServerURL, + ContentConfig: restclient.ContentConfig{ + GroupVersion: &authv1.SchemeGroupVersion, + NegotiatedSerializer: scheme.Codecs, + }, + } + term := NewFakeTerminalWithResponse("Y") + term.Tee(os.Stdout) + + t.Run("ok", func(t *testing.T) { + t.Run("create the dest-dir on-the-fly", func(t *testing.T) { + // given + baseDir, err := os.MkdirTemp("", "sandbox-sre-out-") + require.NoError(t, err) + destDir := filepath.Join(baseDir, "test-dev") + + // when + err = adm.MustGatherNamespace(term, kubeconfig, "test-dev", destDir) + + // then + require.NoError(t, err) + // verify that the files exist + assertFileContents(t, destDir, fileContents) + }) + + t.Run("dest-dir already exists and is empty", func(t *testing.T) { + // given + baseDir, err := os.MkdirTemp("", "sandbox-sre-out-") + require.NoError(t, err) + destDir := filepath.Join(baseDir, "test-dev") + err = os.Mkdir(destDir, 0755) + require.NoError(t, err) + + // when + err = adm.MustGatherNamespace(term, kubeconfig, "test-dev", destDir) + + // then + require.NoError(t, err) + // verify that the files exist + assertFileContents(t, destDir, fileContents) + }) + }) + + t.Run("failure", func(t *testing.T) { + + t.Run("dest-dir already exists but is not empty", func(t *testing.T) { + // given + baseDir, err := os.MkdirTemp("", "sandbox-sre-out-") + require.NoError(t, err) + destDir := filepath.Join(baseDir, "test-dev") + err = os.Mkdir(destDir, 0755) + require.NoError(t, err) + // put some contents + err = os.WriteFile(filepath.Join(destDir, "test.yaml"), []byte("apiVersion; v1"), 0600) + require.NoError(t, err) + + // when + err = adm.MustGatherNamespace(term, kubeconfig, "test-dev", destDir) + + // then + require.NoError(t, err) // no error occurred, but command aborted + assert.Contains(t, term.Output(), fmt.Sprintf("The '%s' dest-dir is not empty. Aborting.", destDir)) + }) + }) + +} + +func assertFileContents(t *testing.T, destDir string, fileContents map[string]string) { + for filename, expectedContents := range fileContents { + actualContents, err := os.ReadFile(filepath.Join(destDir, filename)) + require.NoError(t, err, fmt.Sprintf("'%s' is missing", filename)) + assert.Equal(t, expectedContents, string(actualContents)) + } +} + +func get(path string) gock.MatchFunc { + return func(r *http.Request, _ *gock.Request) (bool, error) { + return r.Method == "GET" && r.URL.Path == path, nil + } +} +func newAPIServer(uri string) { + // gock.Observe(gock.DumpRequest) + gock.New(uri).AddMatcher(get("/api")).Persist().Reply(200).SetHeader("Content-Type", "application/json").BodyString( + `{ + "kind": "APIVersions", + "versions": [ + "v1" + ] + }`, + ) + gock.New(uri).AddMatcher(get("/api/v1")).Persist().Reply(200).BodyString( + `{ + "kind": "APIResourceList", + "groupVersion": "v1", + "resources": [ + { + "name": "bindings", + "singularName": "binding", + "namespaced": true, + "kind": "Binding", + "verbs": [ + "create" + ] + }, + { + "name": "pods", + "singularName": "pod", + "namespaced": true, + "kind": "Pod", + "verbs": [ + "create", + "delete", + "deletecollection", + "get", + "list", + "patch", + "update", + "watch" + ], + "shortNames": [ + "po" + ], + "categories": [ + "all" + ] + }, + { + "name": "pods/log", + "singularName": "", + "namespaced": true, + "kind": "Pod", + "verbs": [ + "get" + ] + }, + { + "name": "podtemplates", + "namespaced": true, + "kind": "PodTemplate", + "verbs": [ + "create", + "delete", + "deletecollection", + "get", + "list", + "patch", + "update", + "watch" + ] + } + ] + }`, + ) + gock.New(uri).AddMatcher(get("/apis")).Persist().Reply(200).SetHeader("Content-Type", "application/json").BodyString( + `{ + "apiVersion": "v1", + "kind": "APIGroupList", + "groups": [ + { + "name": "rbac.authorization.k8s.io", + "versions": [ + { + "groupVersion": "rbac.authorization.k8s.io/v1", + "version": "v1" + } + ], + "preferredVersion": { + "groupVersion": "rbac.authorization.k8s.io/v1", + "version": "v1" + } + } + ] + }`, + ) + gock.New(uri).AddMatcher(get("/api/v1/namespaces/test-dev/pods")).Persist().Reply(200).BodyString( + `{ + "apiVersion": "v1", + "kind": "List", + "items": [ + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "pasta", + "namespace": "test-dev" + }, + "status": { + "containerStatuses": [ + { + "containerID": "cri-o://pasta", + "image": "pasta1:latest", + "name": "container1", + "ready": true, + "started": true + }, + { + "containerID": "cri-o://pasta", + "image": "pasta2:latest", + "name": "container2", + "ready": true, + "started": true + } + ] + } + }, + { + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "cookie", + "namespace": "test-dev" + } + } + ] + }`, + ) + + gock.New(uri).AddMatcher(get("/api/v1/namespaces/test-dev/pods/pasta/log")).MatchParam("container", "^container1$").Persist().Reply(200).BodyString( + `pasta for life!`, + ) + gock.New(uri).AddMatcher(get("/api/v1/namespaces/test-dev/pods/pasta/log")).MatchParam("container", "^container2$").Persist().Reply(200).BodyString( + `pasta everyday!`, + ) + + gock.New(uri).AddMatcher(get("/apis/rbac.authorization.k8s.io/v1")).Persist().Reply(200).BodyString( + `{ + "kind": "APIResourceList", + "apiVersion": "v1", + "groupVersion": "rbac.authorization.k8s.io/v1", + "resources": [ + { + "name": "rolebindings", + "singularName": "rolebinding", + "namespaced": true, + "kind": "RoleBinding", + "verbs": [ + "create", + "delete", + "deletecollection", + "get", + "list", + "patch", + "update", + "watch" + ] + } + ] + }`, + ) + gock.New(uri).AddMatcher(get("/apis/rbac.authorization.k8s.io/v1/namespaces/test-dev/rolebindings")).Persist().Reply(200).BodyString( + `{ + "apiVersion": "v1", + "kind": "List", + "items": [ + { + "apiVersion": "authorization.openshift.io/v1", + "kind": "RoleBinding", + "metadata": { + "name": "admin-foo", + "namespace": "test-dev" + }, + "roleRef": { + "name": "admin" + }, + "subjects": [ + { + "kind": "Group", + "name": "foo" + } + ] + }, + { + "apiVersion": "authorization.openshift.io/v1", + "kind": "RoleBinding", + "metadata": { + "name": "viewer-foo", + "namespace": "test-dev" + }, + "roleRef": { + "name": "viewer" + }, + "subjects": [ + { + "kind": "Group", + "name": "foo" + } + ] + } + ] + }`, + ) + gock.New(uri).AddMatcher(get("/api/v1/namespaces/test-dev/podtemplates")).Persist().Reply(403) + +} + +var fileContents = map[string]string{ + "pod-pasta.yaml": `apiVersion: v1 +kind: Pod +metadata: + name: pasta + namespace: test-dev +status: + containerStatuses: + - containerID: cri-o://pasta + image: pasta1:latest + name: container1 + ready: true + started: true + - containerID: cri-o://pasta + image: pasta2:latest + name: container2 + ready: true + started: true +`, + "pod-pasta-container1.logs": `pasta for life!`, + "pod-pasta-container2.logs": `pasta everyday!`, + "pod-cookie.yaml": `apiVersion: v1 +kind: Pod +metadata: + name: cookie + namespace: test-dev +`, + "rolebinding-admin-foo.yaml": `apiVersion: authorization.openshift.io/v1 +kind: RoleBinding +metadata: + name: admin-foo + namespace: test-dev +roleRef: + name: admin +subjects: + - kind: Group + name: foo +`, + "rolebinding-viewer-foo.yaml": `apiVersion: authorization.openshift.io/v1 +kind: RoleBinding +metadata: + name: viewer-foo + namespace: test-dev +roleRef: + name: viewer +subjects: + - kind: Group + name: foo +`, +} diff --git a/pkg/cmd/adm/register_member.go b/pkg/cmd/adm/register_member.go new file mode 100644 index 0000000..b4b592d --- /dev/null +++ b/pkg/cmd/adm/register_member.go @@ -0,0 +1,162 @@ +package adm + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/cmd/flags" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + "k8s.io/client-go/util/homedir" + + errs "github.com/pkg/errors" + "github.com/spf13/cobra" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + AddClusterScriptDomain = "https://raw.githubusercontent.com/" + AddClusterScriptPath = "codeready-toolchain/toolchain-cicd/master/scripts/add-cluster.sh" + AddClusterScriptURL = AddClusterScriptDomain + AddClusterScriptPath +) + +func NewRegisterMemberCmd() *cobra.Command { + var hostKubeconfig, memberKubeconfig string + cmd := &cobra.Command{ + Use: "register-member", + Short: "Executes add-cluster.sh script", + Long: `Downloads the 'add-cluster.sh' script from the 'toolchain-cicd' repo and calls it twice: once to register the Host cluster in the Member cluster and once to register the Member cluster in the host cluster.`, + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + newCommand := func(name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) + } + return registerMemberCluster(ctx, newCommand, hostKubeconfig, memberKubeconfig) + }, + } + defaultKubeconfigPath := "" + if home := homedir.HomeDir(); home != "" { + defaultKubeconfigPath = filepath.Join(home, ".kube", "config") + } + cmd.Flags().StringVar(&hostKubeconfig, "host-kubeconfig", defaultKubeconfigPath, "Path to the kubeconfig file of the host cluster (default: "+defaultKubeconfigPath+")") + flags.MustMarkRequired(cmd, "host-kubeconfig") + cmd.Flags().StringVar(&memberKubeconfig, "member-kubeconfig", defaultKubeconfigPath, "Path to the kubeconfig file of the member cluster (default: "+defaultKubeconfigPath+")") + flags.MustMarkRequired(cmd, "member-kubeconfig") + return cmd +} + +func registerMemberCluster(ctx *clicontext.CommandContext, newCommand client.CommandCreator, hostKubeconfig, memberKubeconfig string) error { + ctx.AskForConfirmation(ioutils.WithMessagef("register member cluster from kubeconfig %s. Be aware that the sandbox-cli disables automatic approval to prevent new users being provisioned to the new member cluster. "+ + "You will need to enable it again manually.", memberKubeconfig)) + + hostClusterConfig, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + hostClusterClient, err := ctx.NewClient(hostClusterConfig.Token, hostClusterConfig.ServerAPI) + if err != nil { + return err + } + + if err := disableAutomaticApproval(hostClusterConfig, hostClusterClient); err != nil { + return err + } + + if err := runAddClusterScript(ctx, newCommand, configuration.Host, hostKubeconfig, memberKubeconfig); err != nil { + return err + } + if err := runAddClusterScript(ctx, newCommand, configuration.Member, hostKubeconfig, memberKubeconfig); err != nil { + return err + } + + warningMessage := "The automatic approval was disabled!\n Configure the new member cluster in ToolchainConfig and apply the changes to the cluster." + + if err := restartHostOperator(ctx, hostClusterClient, hostClusterConfig); err != nil { + return fmt.Errorf("%w\nIn Additon, there is another warning you should be aware of:\n%s", err, warningMessage) + } + + ctx.Printlnf("!!!!!!!!!!!!!!!") + ctx.Printlnf("!!! WARNING !!!") + ctx.Printlnf("!!!!!!!!!!!!!!!") + ctx.Printlnf(warningMessage) + return nil +} + +func disableAutomaticApproval(hostClusterConfig configuration.ClusterConfig, cl runtimeclient.Client) error { + configs := &toolchainv1alpha1.ToolchainConfigList{} + if err := cl.List(context.TODO(), configs, runtimeclient.InNamespace(hostClusterConfig.SandboxNamespace)); err != nil { + return err + } + + if len(configs.Items) == 0 { + return nil + } + + if len(configs.Items) > 1 { + return fmt.Errorf("there are more than one instance of ToolchainConfig") + } + + toolchainConfig := configs.Items[0] + if toolchainConfig.Spec.Host.AutomaticApproval.Enabled != nil && *toolchainConfig.Spec.Host.AutomaticApproval.Enabled { + enabled := false + toolchainConfig.Spec.Host.AutomaticApproval.Enabled = &enabled + return cl.Update(context.TODO(), &toolchainConfig) + } + return nil +} + +func runAddClusterScript(term ioutils.Terminal, newCommand client.CommandCreator, joiningClusterType configuration.ClusterType, hostKubeconfig, memberKubeconfig string) error { + if !term.AskForConfirmation(ioutils.WithMessagef("register the %s cluster by creating a ToolchainCluster CR, a Secret and a new ServiceAccount resource?", joiningClusterType)) { + return nil + } + + script, err := downloadScript(term) + if err != nil { + return err + } + args := []string{script.Name(), "--type", joiningClusterType.String(), "--host-kubeconfig", hostKubeconfig, "--member-kubeconfig", memberKubeconfig, "--lets-encrypt"} + term.Printlnf("Command to be called: bash %s\n", strings.Join(args, " ")) + bash := newCommand("bash", args...) + bash.Stdout = os.Stdout + bash.Stderr = os.Stderr + return bash.Run() +} + +func downloadScript(term ioutils.Terminal) (*os.File, error) { + resp, err := http.Get(AddClusterScriptURL) + if err != nil { + return nil, errs.Wrapf(err, "unable to get add-script.sh") + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("unable to get add-script.sh - response status %s", resp.Status) + } + defer func() { + if err := resp.Body.Close(); err != nil { + term.Printlnf(err.Error()) + } + }() + // Create the file + file, err := os.CreateTemp("", "add-cluster-*.sh") + if err != nil { + return nil, err + } + defer func() { + if err := file.Close(); err != nil { + term.Printlnf(err.Error()) + } + }() + + // Write the body to file + _, err = io.Copy(file, resp.Body) + return file, err +} diff --git a/pkg/cmd/adm/register_member_test.go b/pkg/cmd/adm/register_member_test.go new file mode 100644 index 0000000..0b87ea8 --- /dev/null +++ b/pkg/cmd/adm/register_member_test.go @@ -0,0 +1,229 @@ +package adm + +import ( + "context" + "fmt" + "strings" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/codeready-toolchain/toolchain-common/pkg/test/config" + _ "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/types" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + hostKubeconfig = "/path/to/host-kubeconfig" + memberKubeconfig = "/path/to/member-kubeconfig" +) + +func TestRegisterMember(t *testing.T) { + // given + SetFileConfig(t, Host(), Member()) + gock.New(AddClusterScriptDomain). + Get(AddClusterScriptPath). + Persist(). + Reply(200) + defer gock.OffAll() + + hostArgs := []string{"--type", "host", "--host-kubeconfig", "/path/to/host-kubeconfig", "--member-kubeconfig", "/path/to/member-kubeconfig", "--lets-encrypt"} + memberArgs := []string{"--type", "member", "--host-kubeconfig", "/path/to/host-kubeconfig", "--member-kubeconfig", "/path/to/member-kubeconfig", "--lets-encrypt"} + var counter int + ocCommandCreator := NewCommandCreator(t, "echo", "bash", + func(t *testing.T, args ...string) { + if counter == 0 { + AssertFirstArgPrefixRestEqual("(.*)/add-cluster-(.*)", hostArgs...)(t, args...) + } else { + AssertFirstArgPrefixRestEqual("(.*)/add-cluster-(.*)", memberArgs...)(t, args...) + } + counter++ + }) + hostDeploymentName := test.NamespacedName("toolchain-host-operator", "host-operator-controller-manager") + deployment := newDeployment(hostDeploymentName, 1) + deployment.Labels = map[string]string{"olm.owner.namespace": "toolchain-host-operator"} + + t.Run("When automatic approval is enabled", func(t *testing.T) { + term := NewFakeTerminalWithResponse("Y") + toolchainConfig := config.NewToolchainConfigObj(t, config.AutomaticApproval().Enabled(true)) + newClient, newRESTClient, fakeClient := NewFakeClients(t, toolchainConfig, deployment) + numberOfUpdateCalls := 0 + fakeClient.MockUpdate = whenDeploymentThenUpdated(t, fakeClient, hostDeploymentName, 1, &numberOfUpdateCalls) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + counter = 0 + + // when + err := registerMemberCluster(ctx, ocCommandCreator, hostKubeconfig, memberKubeconfig) + + // then + require.NoError(t, err) + // on Linux, the output contains `Command to be called: bash /tmp/add-cluster-` + // on macOS, the output contains something like `Command to be called: bash /var/folders/b8/wy8kq7_179l7yswz6gz6qx800000gp/T/add-cluster-369107288.sh` + assert.Contains(t, term.Output(), "Command to be called: bash ") + assert.Contains(t, term.Output(), "add-cluster-") + assert.Contains(t, term.Output(), strings.Join(hostArgs, " ")) + assert.Contains(t, term.Output(), strings.Join(memberArgs, " ")) + assert.Equal(t, 2, counter) + + enabled := false + toolchainConfig.Spec.Host.AutomaticApproval.Enabled = &enabled + AssertToolchainConfigHasSpec(t, fakeClient, test.NamespacedName(toolchainConfig.Namespace, toolchainConfig.Name), toolchainConfig.Spec) + assert.Contains(t, term.Output(), "!!! WARNING !!!") + assert.Contains(t, term.Output(), "The automatic approval was disabled!") + assert.Contains(t, term.Output(), "Configure the new member cluster in ToolchainConfig and apply the changes to the cluster.") + + AssertDeploymentHasReplicas(t, fakeClient, hostDeploymentName, 1) + assert.Equal(t, 2, numberOfUpdateCalls) + }) + + t.Run("When toolchainConfig is not present", func(t *testing.T) { + term := NewFakeTerminalWithResponse("Y") + newClient, newRESTClient, fakeClient := NewFakeClients(t, deployment) + fakeClient.MockUpdate = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.UpdateOption) error { + if _, ok := obj.(*toolchainv1alpha1.ToolchainConfig); ok { + return fmt.Errorf("should not be called") + } + return fakeClient.Client.Update(ctx, obj, opts...) + } + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + counter = 0 + + // when + err := registerMemberCluster(ctx, ocCommandCreator, hostKubeconfig, memberKubeconfig) + + // then + require.NoError(t, err) + assert.Equal(t, 2, counter) + + AssertToolchainConfigDoesNotExist(t, fakeClient, test.NamespacedName(test.HostOperatorNs, "config")) + assert.Contains(t, term.Output(), "!!! WARNING !!!") + assert.Contains(t, term.Output(), "The automatic approval was disabled!") + assert.Contains(t, term.Output(), "Configure the new member cluster in ToolchainConfig and apply the changes to the cluster.") + }) + + t.Run("When automatic approval is disabled", func(t *testing.T) { + term := NewFakeTerminalWithResponse("Y") + toolchainConfig := config.NewToolchainConfigObj(t, config.AutomaticApproval().Enabled(false)) + newClient, newRESTClient, fakeClient := NewFakeClients(t, toolchainConfig, deployment) + fakeClient.MockUpdate = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.UpdateOption) error { + if _, ok := obj.(*toolchainv1alpha1.ToolchainConfig); ok { + return fmt.Errorf("should not be called") + } + return fakeClient.Client.Update(ctx, obj, opts...) + } + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + counter = 0 + + // when + err := registerMemberCluster(ctx, ocCommandCreator, hostKubeconfig, memberKubeconfig) + + // then + require.NoError(t, err) + assert.Equal(t, 2, counter) + + enabled := false + toolchainConfig.Spec.Host.AutomaticApproval.Enabled = &enabled + AssertToolchainConfigHasSpec(t, fakeClient, test.NamespacedName(toolchainConfig.Namespace, toolchainConfig.Name), toolchainConfig.Spec) + assert.Contains(t, term.Output(), "!!! WARNING !!!") + assert.Contains(t, term.Output(), "The automatic approval was disabled!") + assert.Contains(t, term.Output(), "Configure the new member cluster in ToolchainConfig and apply the changes to the cluster.") + }) + + t.Run("When there are two ToolchainConfigs", func(t *testing.T) { + term := NewFakeTerminalWithResponse("Y") + toolchainConfig := config.NewToolchainConfigObj(t, config.AutomaticApproval().Enabled(false)) + toolchainConfig2 := config.NewToolchainConfigObj(t, config.AutomaticApproval().Enabled(true)) + toolchainConfig2.Name = "config2" + newClient, newRESTClient, fakeClient := NewFakeClients(t, toolchainConfig, toolchainConfig2, deployment) + fakeClient.MockUpdate = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.UpdateOption) error { + return fmt.Errorf("should not be called") + } + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + counter = 0 + + // when + err := registerMemberCluster(ctx, ocCommandCreator, hostKubeconfig, memberKubeconfig) + + // then + require.Error(t, err) + assert.Equal(t, 0, counter) + }) +} + +func TestRunAddClusterScriptSuccess(t *testing.T) { + // given + SetFileConfig(t, Host(), Member()) + gock.New(AddClusterScriptDomain). + Get(AddClusterScriptPath). + Persist(). + Reply(200) + defer gock.OffAll() + term := NewFakeTerminalWithResponse("Y") + + for _, clusterType := range configuration.ClusterTypes { + t.Run("for cluster name: "+clusterType.String(), func(t *testing.T) { + // given + + expArgs := []string{"--type", clusterType.String(), "--host-kubeconfig", "/path/to/host-kubeconfig", "--member-kubeconfig", "/path/to/member-kubeconfig", "--lets-encrypt"} + ocCommandCreator := NewCommandCreator(t, "echo", "bash", + AssertFirstArgPrefixRestEqual("(.*)/add-cluster-(.*)", expArgs...)) + + // when + err := runAddClusterScript(term, ocCommandCreator, clusterType, hostKubeconfig, memberKubeconfig) + + // then + require.NoError(t, err) + // on Linux, the output contains `Command to be called: bash /tmp/add-cluster-` + // on macOS, the output contains something like `Command to be called: bash /var/folders/b8/wy8kq7_179l7yswz6gz6qx800000gp/T/add-cluster-369107288.sh` + assert.Contains(t, term.Output(), "Command to be called: bash ") + assert.Contains(t, term.Output(), "add-cluster-") + assert.Contains(t, term.Output(), strings.Join(expArgs, " ")) + }) + } +} + +func TestRunAddClusterScriptFailed(t *testing.T) { + // given + SetFileConfig(t, Host(), Member()) + gock.New(AddClusterScriptDomain). + Get(AddClusterScriptPath). + Persist(). + Reply(404) + defer gock.OffAll() + + for _, clusterType := range configuration.ClusterTypes { + + t.Run("for cluster name: "+clusterType.String(), func(t *testing.T) { + // given + expArgs := []string{"--type", clusterType.String(), "--host-kubeconfig", "/path/to/host-kubeconfig", "--member-kubeconfig", "/path/to/member-kubeconfig", "--lets-encrypt"} + ocCommandCreator := NewCommandCreator(t, "echo", "bash", + AssertFirstArgPrefixRestEqual("(.*)/add-cluster-(.*)", expArgs...)) + term := NewFakeTerminalWithResponse("Y") + + // when + err := runAddClusterScript(term, ocCommandCreator, clusterType, hostKubeconfig, memberKubeconfig) + + // then + require.Error(t, err) + assert.NotContains(t, term.Output(), "Command to be called") + }) + } +} + +func whenDeploymentThenUpdated(t *testing.T, fakeClient *test.FakeClient, namespacedName types.NamespacedName, currentReplicas int32, numberOfUpdateCalls *int) func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.UpdateOption) error { + return func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.UpdateOption) error { + if deployment, ok := obj.(*appsv1.Deployment); ok { + checkDeploymentBeingUpdated(t, fakeClient, namespacedName, currentReplicas, numberOfUpdateCalls, deployment) + } + return fakeClient.Client.Update(ctx, obj, opts...) + } +} diff --git a/pkg/cmd/adm/restart.go b/pkg/cmd/adm/restart.go new file mode 100644 index 0000000..cd10361 --- /dev/null +++ b/pkg/cmd/adm/restart.go @@ -0,0 +1,158 @@ +package adm + +import ( + "context" + "fmt" + "time" + + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/cmd/flags" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" + appsv1 "k8s.io/api/apps/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewRestartCmd() *cobra.Command { + var targetCluster string + command := &cobra.Command{ + Use: "restart -t ", + Short: "Restarts a deployment", + Long: `Restarts the deployment with the given name in the operator namespace. +If no deployment name is provided, then it lists all existing deployments in the namespace.`, + Args: cobra.RangeArgs(0, 1), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return restart(ctx, targetCluster, args...) + }, + } + command.Flags().StringVarP(&targetCluster, "target-cluster", "t", "", "The target cluster") + flags.MustMarkRequired(command, "target-cluster") + return command +} + +func restart(ctx *clicontext.CommandContext, clusterName string, deployments ...string) error { + cfg, err := configuration.LoadClusterConfig(ctx, clusterName) + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + + if len(deployments) == 0 { + err := printExistingDeployments(ctx.Terminal, cl, cfg) + if err != nil { + ctx.Terminal.Printlnf("\nERROR: Failed to list existing deployments\n :%s", err.Error()) + } + return fmt.Errorf("at least one deployment name is required, include one or more of the above deployments to restart") + } + deploymentName := deployments[0] + + if !ctx.AskForConfirmation( + ioutils.WithMessagef("restart the deployment '%s' in namespace '%s'", deploymentName, cfg.SandboxNamespace)) { + return nil + } + return restartDeployment(ctx, cl, cfg, deploymentName) +} + +func restartDeployment(ctx *clicontext.CommandContext, cl runtimeclient.Client, cfg configuration.ClusterConfig, deploymentName string) error { + namespacedName := types.NamespacedName{ + Namespace: cfg.SandboxNamespace, + Name: deploymentName, + } + + originalReplicas, err := scaleToZero(cl, namespacedName) + if err != nil { + if apierrors.IsNotFound(err) { + ctx.Printlnf("\nERROR: The given deployment '%s' wasn't found.", deploymentName) + return printExistingDeployments(ctx, cl, cfg) + } + return err + } + ctx.Println("The deployment was scaled to 0") + if err := scaleBack(ctx, cl, namespacedName, originalReplicas); err != nil { + ctx.Printlnf("Scaling the deployment '%s' in namespace '%s' back to '%d' replicas wasn't successful", originalReplicas) + ctx.Println("Please, try to contact administrators to scale the deployment back manually") + return err + } + + ctx.Printlnf("The deployment was scaled back to '%d'", originalReplicas) + return nil +} + +func restartHostOperator(ctx *clicontext.CommandContext, hostClient runtimeclient.Client, hostConfig configuration.ClusterConfig) error { + deployments := &appsv1.DeploymentList{} + if err := hostClient.List(context.TODO(), deployments, + runtimeclient.InNamespace(hostConfig.SandboxNamespace), + runtimeclient.MatchingLabels{"olm.owner.namespace": "toolchain-host-operator"}); err != nil { + return err + } + if len(deployments.Items) != 1 { + return fmt.Errorf("there should be a single deployment matching the label olm.owner.namespace=toolchain-host-operator in %s ns, but %d was found. "+ + "It's not possible to restart the Host Operator deployment", hostConfig.SandboxNamespace, len(deployments.Items)) + } + + return restartDeployment(ctx, hostClient, hostConfig, deployments.Items[0].Name) +} + +func printExistingDeployments(term ioutils.Terminal, cl runtimeclient.Client, cfg configuration.ClusterConfig) error { + deployments := &appsv1.DeploymentList{} + if err := cl.List(context.TODO(), deployments, runtimeclient.InNamespace(cfg.SandboxNamespace)); err != nil { + return err + } + deploymentList := "\n" + for _, deployment := range deployments.Items { + deploymentList += fmt.Sprintf("%s\n", deployment.Name) + } + term.PrintContextSeparatorWithBodyf(deploymentList, "Existing deployments in %s namespace", cfg.SandboxNamespace) + return nil +} + +func scaleToZero(cl runtimeclient.Client, namespacedName types.NamespacedName) (int32, error) { + + // get the deployment + deployment := &appsv1.Deployment{} + if err := cl.Get(context.TODO(), namespacedName, deployment); err != nil { + return 0, err + } + // keep original number of replicas so we can bring it back + originalReplicas := *deployment.Spec.Replicas + zero := int32(0) + deployment.Spec.Replicas = &zero + + // update the deployment so it scales to zero + return originalReplicas, cl.Update(context.TODO(), deployment) +} + +func scaleBack(term ioutils.Terminal, cl runtimeclient.Client, namespacedName types.NamespacedName, originalReplicas int32) error { + return wait.Poll(500*time.Millisecond, 10*time.Second, func() (done bool, err error) { + term.Println("") + term.Printlnf("Trying to scale the deployment back to '%d'", originalReplicas) + // get the updated + deployment := &appsv1.Deployment{} + if err := cl.Get(context.TODO(), namespacedName, deployment); err != nil { + return false, err + } + // check if the replicas number wasn't already reset by a controller + if *deployment.Spec.Replicas == originalReplicas { + return true, nil + } + // set the original + deployment.Spec.Replicas = &originalReplicas + // and update to scale back + if err := cl.Update(context.TODO(), deployment); err != nil { + term.Printlnf("error updating Deployment '%s': %s. Will retry again...", namespacedName.Name, err.Error()) + return false, nil + } + return true, nil + }) +} diff --git a/pkg/cmd/adm/restart_test.go b/pkg/cmd/adm/restart_test.go new file mode 100644 index 0000000..10f21e7 --- /dev/null +++ b/pkg/cmd/adm/restart_test.go @@ -0,0 +1,253 @@ +package adm + +import ( + "context" + "fmt" + "testing" + + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestRestartDeployment(t *testing.T) { + // given + SetFileConfig(t, Host(), Member()) + + for _, clusterName := range []string{"host", "member1"} { + clusterType := configuration.Host + if clusterName != "host" { + clusterType = configuration.Member + } + namespace := fmt.Sprintf("toolchain-%s-operator", clusterType) + namespacedName := types.NamespacedName{ + Namespace: namespace, + Name: "cool-deployment", + } + term := NewFakeTerminalWithResponse("Y") + + t.Run("restart is successful for "+clusterName, func(t *testing.T) { + // given + deployment := newDeployment(namespacedName, 3) + newClient, newRESTClient, fakeClient := NewFakeClients(t, deployment) + numberOfUpdateCalls := 0 + fakeClient.MockUpdate = requireDeploymentBeingUpdated(t, fakeClient, namespacedName, 3, &numberOfUpdateCalls) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := restart(ctx, clusterName, "cool-deployment") + + // then + require.NoError(t, err) + AssertDeploymentHasReplicas(t, fakeClient, namespacedName, 3) + assert.Equal(t, 2, numberOfUpdateCalls) + + }) + + t.Run("list deployments when no deployment name is provided for "+clusterName, func(t *testing.T) { + // given + deployment := newDeployment(namespacedName, 3) + newClient, newRESTClient, fakeClient := NewFakeClients(t, deployment) + numberOfUpdateCalls := 0 + fakeClient.MockUpdate = requireDeploymentBeingUpdated(t, fakeClient, namespacedName, 3, &numberOfUpdateCalls) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := restart(ctx, clusterName) + + // then + require.EqualError(t, err, "at least one deployment name is required, include one or more of the above deployments to restart") + AssertDeploymentHasReplicas(t, fakeClient, namespacedName, 3) + assert.Equal(t, 0, numberOfUpdateCalls) + assert.Contains(t, term.Output(), fmt.Sprintf("Existing deployments in toolchain-%s-operator namespace", clusterType)) + assert.Contains(t, term.Output(), "cool-deployment") + }) + + t.Run("restart fails - cannot get the deployment for "+clusterName, func(t *testing.T) { + // given + deployment := newDeployment(namespacedName, 3) + newClient, newRESTClient, fakeClient := NewFakeClients(t, deployment) + numberOfUpdateCalls := 0 + fakeClient.MockUpdate = requireDeploymentBeingUpdated(t, fakeClient, namespacedName, 3, &numberOfUpdateCalls) + fakeClient.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("some error") + } + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := restart(ctx, clusterName, "cool-deployment") + + // then + require.Error(t, err) + fakeClient.MockGet = nil + AssertDeploymentHasReplicas(t, fakeClient, namespacedName, 3) + assert.Equal(t, 0, numberOfUpdateCalls) + }) + + t.Run("restart fails - deployment not found for "+clusterName, func(t *testing.T) { + // given + deployment := newDeployment(namespacedName, 3) + newClient, newRESTClient, fakeClient := NewFakeClients(t, deployment) + numberOfUpdateCalls := 0 + fakeClient.MockUpdate = requireDeploymentBeingUpdated(t, fakeClient, namespacedName, 3, &numberOfUpdateCalls) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := restart(ctx, clusterName, "wrong-deployment") + + // then + require.NoError(t, err) + AssertDeploymentHasReplicas(t, fakeClient, namespacedName, 3) + assert.Equal(t, 0, numberOfUpdateCalls) + assert.Contains(t, term.Output(), "ERROR: The given deployment 'wrong-deployment' wasn't found.") + assert.Contains(t, term.Output(), fmt.Sprintf("Existing deployments in toolchain-%s-operator namespace", clusterType)) + assert.Contains(t, term.Output(), "cool-deployment") + }) + } +} + +func TestRestartDeploymentWithInsufficientPermissions(t *testing.T) { + // given + SetFileConfig(t, Host(NoToken()), Member(NoToken())) + for _, clusterName := range []string{"host", "member1"} { + // given + clusterType := configuration.Host + if clusterName != "host" { + clusterType = configuration.Member + } + namespace := fmt.Sprintf("toolchain-%s-operator", clusterType) + namespacedName := types.NamespacedName{ + Namespace: namespace, + Name: "cool-deployment", + } + deployment := newDeployment(namespacedName, 3) + newClient, newRESTClient, fakeClient := NewFakeClients(t, deployment) + numberOfUpdateCalls := 0 + fakeClient.MockUpdate = requireDeploymentBeingUpdated(t, fakeClient, namespacedName, 3, &numberOfUpdateCalls) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := restart(ctx, clusterName, "cool-deployment") + + // then + require.Error(t, err) + assert.Equal(t, 0, numberOfUpdateCalls) + AssertDeploymentHasReplicas(t, fakeClient, namespacedName, 3) + } +} + +func TestRestartHostOperator(t *testing.T) { + // given + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("") // it should not read the input + cfg, err := configuration.LoadClusterConfig(term, "host") + require.NoError(t, err) + namespacedName := types.NamespacedName{ + Namespace: "toolchain-host-operator", + Name: "host-operator-controller-manager", + } + + t.Run("host deployment is present and restart successful", func(t *testing.T) { + // given + deployment := newDeployment(namespacedName, 1) + deployment.Labels = map[string]string{"olm.owner.namespace": "toolchain-host-operator"} + newClient, newRESTClient, fakeClient := NewFakeClients(t, deployment) + numberOfUpdateCalls := 0 + fakeClient.MockUpdate = requireDeploymentBeingUpdated(t, fakeClient, namespacedName, 1, &numberOfUpdateCalls) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := restartHostOperator(ctx, fakeClient, cfg) + + // then + require.NoError(t, err) + AssertDeploymentHasReplicas(t, fakeClient, namespacedName, 1) + assert.Equal(t, 2, numberOfUpdateCalls) + }) + + t.Run("host deployment with the label is not present - restart fails", func(t *testing.T) { + // given + deployment := newDeployment(namespacedName, 1) + newClient, newRESTClient, fakeClient := NewFakeClients(t, deployment) + numberOfUpdateCalls := 0 + fakeClient.MockUpdate = requireDeploymentBeingUpdated(t, fakeClient, namespacedName, 1, &numberOfUpdateCalls) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := restartHostOperator(ctx, fakeClient, cfg) + + // then + require.Error(t, err) + AssertDeploymentHasReplicas(t, fakeClient, namespacedName, 1) + assert.Equal(t, 0, numberOfUpdateCalls) + }) + + t.Run("there are more deployments with the host operator label - restart fails", func(t *testing.T) { + // given + deployment := newDeployment(namespacedName, 1) + deployment.Labels = map[string]string{"olm.owner.namespace": "toolchain-host-operator"} + deployment2 := deployment.DeepCopy() + deployment2.Name = "another" + newClient, newRESTClient, fakeClient := NewFakeClients(t, deployment, deployment2) + numberOfUpdateCalls := 0 + fakeClient.MockUpdate = requireDeploymentBeingUpdated(t, fakeClient, namespacedName, 1, &numberOfUpdateCalls) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := restartHostOperator(ctx, fakeClient, cfg) + + // then + require.Error(t, err) + AssertDeploymentHasReplicas(t, fakeClient, namespacedName, 1) + assert.Equal(t, 0, numberOfUpdateCalls) + }) +} + +func newDeployment(namespacedName types.NamespacedName, replicas int32) *appsv1.Deployment { //nolint:unparam + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespacedName.Namespace, + Name: namespacedName.Name, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + }, + } +} + +func requireDeploymentBeingUpdated(t *testing.T, fakeClient *test.FakeClient, namespacedName types.NamespacedName, currentReplicas int32, numberOfUpdateCalls *int) func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.UpdateOption) error { + return func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.UpdateOption) error { + deployment, ok := obj.(*appsv1.Deployment) + require.True(t, ok) + checkDeploymentBeingUpdated(t, fakeClient, namespacedName, currentReplicas, numberOfUpdateCalls, deployment) + return fakeClient.Client.Update(ctx, obj, opts...) + } +} + +func checkDeploymentBeingUpdated(t *testing.T, fakeClient *test.FakeClient, namespacedName types.NamespacedName, currentReplicas int32, numberOfUpdateCalls *int, deployment *appsv1.Deployment) { + // on the first call, we should have a deployment with 3 replicas ("current") and request to scale down to 0 ("requested") + // on the other calls, it's the opposite + if *numberOfUpdateCalls == 0 { + // check the current deployment's replicas field + AssertDeploymentHasReplicas(t, fakeClient, namespacedName, currentReplicas) + // check the requested deployment's replicas field + assert.Equal(t, int32(0), *deployment.Spec.Replicas) + } else { + // check the current deployment's replicas field + AssertDeploymentHasReplicas(t, fakeClient, namespacedName, 0) + // check the requested deployment's replicas field + assert.Equal(t, int32(currentReplicas), *deployment.Spec.Replicas) + } + *numberOfUpdateCalls++ +} diff --git a/pkg/cmd/adm/setup.go b/pkg/cmd/adm/setup.go new file mode 100644 index 0000000..d36f931 --- /dev/null +++ b/pkg/cmd/adm/setup.go @@ -0,0 +1,103 @@ +package adm + +import ( + "os" + "path/filepath" + + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/cmd/flags" + "github.com/kubesaw/ksctl/pkg/configuration" + "github.com/kubesaw/ksctl/pkg/ioutils" + "github.com/kubesaw/ksctl/resources" + errs "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type setupFlags struct { + sandboxConfigFile, outDir, hostRootDir, memberRootDir string + singleCluster bool +} + +func NewSetupCmd() *cobra.Command { + f := setupFlags{} + command := &cobra.Command{ + Use: "setup --sandbox-config= --out-dir ", + Example: `sandbox-cli adm setup ./path/to/sandbox.openshiftapps.com/sandbox-config.yaml --out-dir ./components/auth/devsandbox-production +sandbox-cli adm setup ./path/to/sandbox-stage.openshiftapps.com/sandbox-config.yaml --out-dir ./components/auth/devsandbox-staging -s`, + Short: "Generates user-management manifests", + Long: `Reads the sandbox-config.yaml file and based on the content it generates user-management RBAC and manifests.`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, _ []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + return Setup(term, resources.Resources, f) + }, + } + command.Flags().StringVarP(&f.sandboxConfigFile, "sandbox-config", "c", "", "Use the given sandbox config file") + command.Flags().StringVarP(&f.outDir, "out-dir", "o", "", "Directory where generated manifests should be stored") + command.Flags().BoolVarP(&f.singleCluster, "single-cluster", "s", false, "If host and member are deployed to the same cluster") + command.Flags().StringVar(&f.hostRootDir, "host-root-dir", "host", "The root directory name for host manifests") + command.Flags().StringVar(&f.memberRootDir, "member-root-dir", "member", "The root directory name for member manifests") + + flags.MustMarkRequired(command, "sandbox-config") + flags.MustMarkRequired(command, "out-dir") + + return command +} + +func Setup(term ioutils.Terminal, files assets.FS, flags setupFlags) error { + if err := client.AddToScheme(); err != nil { + return err + } + abs, err := filepath.Abs(flags.outDir) + if err != nil { + return err + } + flags.outDir = abs + + // Get the unmarshalled version of sandbox-config.yaml + sandboxEnvConfig, err := assets.GetSandboxEnvironmentConfig(flags.sandboxConfigFile) + if err != nil { + return errs.Wrapf(err, "unable get sandbox-config.yaml file from %s", flags.sandboxConfigFile) + } + err = os.RemoveAll(flags.outDir) + if err != nil { + return err + } + ctx := &setupContext{ + Terminal: term, + sandboxEnvConfig: sandboxEnvConfig, + setupFlags: flags, + files: files, + } + objsCache := objectsCache{} + if err := ensureCluster(ctx, configuration.Host, objsCache); err != nil { + return err + } + if err := ensureCluster(ctx, configuration.Member, objsCache); err != nil { + return err + } + return objsCache.writeManifests(ctx) +} + +type setupContext struct { + ioutils.Terminal + setupFlags + sandboxEnvConfig *assets.SandboxEnvironmentConfig + files assets.FS +} + +func ensureCluster(ctx *setupContext, clusterType configuration.ClusterType, cache objectsCache) error { + ctx.PrintContextSeparatorf("Generating manifests for %s cluster type", clusterType) + + clusterCtx := &clusterContext{ + setupContext: ctx, + clusterType: clusterType, + } + + if err := ensureServiceAccounts(clusterCtx, cache); err != nil { + return err + } + return ensureUsers(clusterCtx, cache) + +} diff --git a/pkg/cmd/adm/setup_assertion_test.go b/pkg/cmd/adm/setup_assertion_test.go new file mode 100644 index 0000000..7e93c8b --- /dev/null +++ b/pkg/cmd/adm/setup_assertion_test.go @@ -0,0 +1,435 @@ +package adm + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + commonidentity "github.com/codeready-toolchain/toolchain-common/pkg/identity" + "github.com/ghodss/yaml" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/kustomize/api/types" + + "github.com/kubesaw/ksctl/pkg/configuration" + "github.com/kubesaw/ksctl/pkg/utils" + userv1 "github.com/openshift/api/user/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// storage assertions + +type storageAssertion interface { + assertObject(namespace, name string, object runtimeclient.Object, contentAssertions ...func()) + listObjects(kind string, object runtimeclient.Object) ([]runtimeclient.Object, error) +} + +type storageAssertionImpl struct { + storageAssertion + out string + rootDirName string + t *testing.T +} + +// cache based structure related assertions + +type objectsCacheAssertion struct { + *storageAssertionImpl + cache objectsCache +} + +func inObjectCache(t *testing.T, out, rootDirName string, cache objectsCache) *objectsCacheAssertion { + cacheAssertion := &objectsCacheAssertion{ + storageAssertionImpl: &storageAssertionImpl{ + out: out, + t: t, + rootDirName: rootDirName, + }, + cache: cache, + } + cacheAssertion.storageAssertion = cacheAssertion + return cacheAssertion +} + +func (a *objectsCacheAssertion) assertObjectDoesNotExist(namespace, name string, object runtimeclient.Object) *objectsCacheAssertion { //nolint unparam + gvks, _, err := scheme.Scheme.ObjectKinds(object) + require.NoError(a.t, err) + require.Len(a.t, gvks, 1) + plural, _ := meta.UnsafeGuessKindToResource(gvks[0]) + filePath := getFilePath(a.out, a.rootDirName, namespace, plural.Resource, name) + assert.Nil(a.t, a.cache[filePath]) + return a +} + +func (a *objectsCacheAssertion) listObjects(resource string, _ runtimeclient.Object) ([]runtimeclient.Object, error) { + var objects []runtimeclient.Object + prefix := filepath.Join(a.out, a.rootDirName) + for path, obj := range a.cache { + if strings.HasPrefix(path, prefix) && filepath.Base(filepath.Dir(path)) == resource { + object := obj + objects = append(objects, object) + } + } + return objects, nil +} + +func (a *objectsCacheAssertion) assertObject(namespace, name string, object runtimeclient.Object, contentAssertions ...func()) { + gvks, _, err := scheme.Scheme.ObjectKinds(object) + require.NoError(a.t, err) + require.Len(a.t, gvks, 1) + plural, _ := meta.UnsafeGuessKindToResource(gvks[0]) + filePath := getFilePath(a.out, a.rootDirName, namespace, plural.Resource, name) + require.Contains(a.t, a.cache, filePath) + obj := a.cache[filePath] + bytes, err := json.Marshal(obj) + require.NoError(a.t, err) + err = json.Unmarshal(bytes, object) + require.NoError(a.t, err) + genericObjectAssertion(a.t, name, namespace, gvks[0], object) + for _, assertContent := range contentAssertions { + assertContent() + } +} + +func genericObjectAssertion(t *testing.T, name, namespace string, gvk schema.GroupVersionKind, object runtimeclient.Object) { + assert.Equal(t, name, object.GetName()) + assert.Equal(t, namespace, object.GetNamespace()) + assert.Equal(t, gvk, object.GetObjectKind().GroupVersionKind()) +} + +// file based and kustomize structure related assertions + +func assertNoClusterTypeEntry(t *testing.T, out string, clusterType configuration.ClusterType) { + _, err := os.Stat(filepath.Join(out, clusterType.String())) + require.True(t, os.IsNotExist(err)) +} + +type kStructureAssertion struct { + *storageAssertionImpl +} + +func inKStructure(t *testing.T, out, rootDirName string) *kStructureAssertion { + kAssertion := &kStructureAssertion{ + storageAssertionImpl: &storageAssertionImpl{ + out: out, + t: t, + rootDirName: rootDirName, + }, + } + kAssertion.storageAssertion = kAssertion + return kAssertion +} + +func (a *kStructureAssertion) listObjects(kind string, object runtimeclient.Object) ([]runtimeclient.Object, error) { + var objects []runtimeclient.Object + + err := filepath.WalkDir(filepath.Join(a.out, a.rootDirName), func(path string, dirEntry fs.DirEntry, err error) error { + if filepath.Base(filepath.Dir(path)) == kind && dirEntry.Name() != "kustomization.yaml" { + obj := object.DeepCopyObject() + objFile, err := os.ReadFile(path) + require.NoError(a.t, err) + err = yaml.Unmarshal(objFile, obj) + require.NoError(a.t, err) + objects = append(objects, obj.(runtimeclient.Object)) + } + return nil + }) + return objects, err +} + +func (a *kStructureAssertion) assertObject(namespace, name string, object runtimeclient.Object, contentAssertions ...func()) { + gvks, _, err := scheme.Scheme.ObjectKinds(object) + require.NoError(a.t, err) + require.Len(a.t, gvks, 1) + plural, _ := meta.UnsafeGuessKindToResource(gvks[0]) + filePath := getFilePath(a.out, a.rootDirName, namespace, plural.Resource, name) + assertObjectAsFile(a.t, filePath, namespace, name, object, contentAssertions...) + assertKustomizationFiles(a.t, a.out, a.rootDirName, filePath) +} + +func assertObjectAsFile(t *testing.T, filePath, namespace, name string, object runtimeclient.Object, contentAssertions ...func()) { + file, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(file), header), "every file generated by cli should container a header") + assert.NotContains(t, string(file), "creationTimestamp") + assert.NotContains(t, string(file), "user: {}") + err = yaml.Unmarshal(file, object) + require.NoError(t, err) + + gvks, _, err := scheme.Scheme.ObjectKinds(object) + require.NoError(t, err) + require.Len(t, gvks, 1) + genericObjectAssertion(t, name, namespace, gvks[0], object) + + for _, assertContent := range contentAssertions { + assertContent() + } +} + +func assertKustomizationFiles(t *testing.T, out, rootDirName string, filePath string) { + kFilePath := filepath.Join(filepath.Dir(filePath), "kustomization.yaml") + assertKustomizationFile(t, kFilePath, filepath.Base(filePath), true) + + if filepath.Join(out, rootDirName) != filepath.Dir(filePath) { + assertKustomizationFiles(t, out, rootDirName, filepath.Dir(kFilePath)) + } else { + kFilePathBase := filepath.Join(baseDirectory(out), "kustomization.yaml") + basePresent := false + if _, err := os.Stat(kFilePathBase); err != nil && !os.IsNotExist(err) { + require.NoError(t, err) + } else if err == nil { + basePresent = true + assertKustomizationFile(t, kFilePathBase, "../base", false) + } + switch rootDirName { + case "member": + assertKustomizationFile(t, kFilePath, "../base", basePresent) + case "host": + assertKustomizationFile(t, kFilePath, "../base", false) + } + } +} + +func assertKustomizationFile(t *testing.T, kFilePath, item string, shouldBePresent bool) { + file, err := os.ReadFile(kFilePath) + require.NoError(t, err) + + kustomization := &types.Kustomization{} + err = yaml.Unmarshal(file, kustomization) + require.NoError(t, err) + + assert.Equal(t, types.KustomizationVersion, kustomization.APIVersion) + assert.Equal(t, types.KustomizationKind, kustomization.Kind) + if shouldBePresent { + assert.Contains(t, kustomization.Resources, item) + } else { + assert.NotContains(t, kustomization.Resources, item) + } +} + +func getDirPath(out, rootDir, namespace, kind string) string { + rootDirPath := filepath.Join(out, rootDir) + dirPath := filepath.Join(rootDirPath, "namespace-scoped", namespace, strings.ToLower(kind)) + if namespace == "" { + dirPath = filepath.Join(rootDirPath, "cluster-scoped", strings.ToLower(kind)) + } + return dirPath +} +func getFilePath(out, rootDir, namespace, resource, objName string) string { + path := filepath.Join(getDirPath(out, rootDir, namespace, resource), fmt.Sprintf("%s.yaml", objName)) + path = strings.ReplaceAll(path, ":", "-") + return path +} + +// permission assertions + +type permissionAssertion struct { + *storageAssertionImpl + subject rbacv1.Subject + expLabels map[string]string +} + +func newPermissionAssertion(storageAssertion *storageAssertionImpl, subjNamespace, subjName, subjKind string) permissionAssertion { + return permissionAssertion{ + storageAssertionImpl: storageAssertion, + subject: rbacv1.Subject{ + Kind: subjKind, + Name: subjName, + Namespace: subjNamespace, + }, + expLabels: map[string]string{ + "provider": "sandbox-sre", + }, + } +} + +func (a *storageAssertionImpl) assertSa(namespace, name string) permissionAssertion { + splitName := strings.Split(name, "-") + + sa := &corev1.ServiceAccount{} + a.assertObject(namespace, name, sa, func() { + expLabels := map[string]string{ + "provider": "sandbox-sre", + "username": splitName[len(splitName)-1], + } + assert.Equal(a.t, expLabels, sa.Labels) + assert.Equal(a.t, ignoreExtraneousAnnotations, sa.Annotations) + }) + return newPermissionAssertion(a, namespace, name, "ServiceAccount") +} + +type userAssertion struct { + permissionAssertion + expLabels map[string]string + userName string + outDir string +} + +func (a *storageAssertionImpl) assertUser(name string) userAssertion { + expLabels := map[string]string{ + "provider": "sandbox-sre", + "username": name, + } + + userObj := &userv1.User{} + a.assertObject("", name, userObj, func() { + assert.Equal(a.t, expLabels, userObj.Labels) + assert.Equal(a.t, ignoreExtraneousAnnotations, userObj.Annotations) + }) + + return userAssertion{ + permissionAssertion: newPermissionAssertion(a, "", name, "User"), + expLabels: expLabels, + userName: name, + outDir: a.out, + } +} + +func (a userAssertion) hasIdentity(ID string) userAssertion { + ins := commonidentity.NewIdentityNamingStandard(ID, "DevSandbox") + src := &userv1.Identity{} + ins.ApplyToIdentity(src) + + identity := &userv1.Identity{} + a.assertObject("", ins.IdentityName(), identity, func() { + assert.Equal(a.t, a.expLabels, identity.Labels) + assert.Equal(a.t, src.ProviderName, identity.ProviderName) + assert.Equal(a.t, src.ProviderUserName, identity.ProviderUserName) + assert.Equal(a.t, ignoreExtraneousAnnotations, identity.Annotations) + }) + + return a +} + +type groupsUserBelongsTo []string +type extraGroupsPresentInCluster []string + +func groups(groups ...string) groupsUserBelongsTo { + return groupsUserBelongsTo(groups) +} + +func extraGroupsUserIsNotPartOf(groups ...string) extraGroupsPresentInCluster { + return extraGroupsPresentInCluster(groups) +} + +func (a userAssertion) belongsToGroups(groups groupsUserBelongsTo, extraGroups extraGroupsPresentInCluster) userAssertion { + presentGroups, err := a.listObjects("groups", &userv1.Group{}) + require.NoError(a.t, err) + allGroupsExpected := append(extraGroups, groups...) + require.Len(a.t, presentGroups, len(allGroupsExpected)) + for _, group := range presentGroups { + assert.Contains(a.t, allGroupsExpected, group.GetName()) + } + + for _, groupObj := range presentGroups { + expLabels := map[string]string{ + "provider": "sandbox-sre", + } + assert.Equal(a.t, expLabels, groupObj.GetLabels()) + group := groupObj.(*userv1.Group) + if utils.Contains(groups, groupObj.GetName()) { + assert.Contains(a.t, group.Users, a.userName, "the user %s should be present in group %s, Actual: %v", a.userName, group.Name, group.Users) + } else { + assert.NotContains(a.t, group.Users, a.userName, "the user %s should NOT be present in group %s, Actual: %v", a.userName, group.Name, group.Users) + } + } + return a +} + +func (a *storageAssertionImpl) assertThatGroupHasUsers(name string, usernames ...string) *storageAssertionImpl { + group := &userv1.Group{} + a.assertObject("", name, group, func() { + expLabels := map[string]string{ + "provider": "sandbox-sre", + } + assert.Equal(a.t, expLabels, group.Labels) + sort.Strings(group.Users) + sort.Strings(usernames) + assert.Equal(a.t, userv1.OptionalNames(usernames), group.Users) + }) + return a +} + +func (a permissionAssertion) hasRole(namespace, roleName, rolebindingName string) permissionAssertion { + a.assertRole(namespace, roleName) + return a.hasRoleBinding(namespace, roleName, rolebindingName, "Role") +} + +func (a permissionAssertion) hasNsClusterRole(namespace, roleName, rolebindingName string) permissionAssertion { + return a.hasRoleBinding(namespace, roleName, rolebindingName, "ClusterRole") +} + +func (a permissionAssertion) hasRoleBinding(namespace, roleName, rolebindingName, kind string) permissionAssertion { + rb := &rbacv1.RoleBinding{} + a.assertObject(namespace, rolebindingName, rb, + func() { + roleRef := rbacv1.RoleRef{ + Kind: kind, + APIGroup: "rbac.authorization.k8s.io", + Name: roleName, + } + assert.Equal(a.t, roleRef, rb.RoleRef) + assert.Equal(a.t, a.expLabels, rb.Labels) + require.Len(a.t, rb.Subjects, 1) + assert.Equal(a.t, a.subject, rb.Subjects[0]) + }) + return a +} + +func (a permissionAssertion) hasClusterRoleBinding(clusterRoleName, clusterRoleBindingName string) permissionAssertion { //nolint:unparam + rb := &rbacv1.ClusterRoleBinding{} + a.assertObject("", clusterRoleBindingName, rb, + func() { + roleRef := rbacv1.RoleRef{ + Kind: "ClusterRole", + APIGroup: "rbac.authorization.k8s.io", + Name: clusterRoleName, + } + assert.Equal(a.t, roleRef, rb.RoleRef) + assert.Equal(a.t, a.expLabels, rb.Labels) + require.Len(a.t, rb.Subjects, 1) + assert.Equal(a.t, a.subject, rb.Subjects[0]) + }) + return a +} + +// role assertions + +type roleContentAssertion func(*testing.T, *rbacv1.Role) + +func hasSameRulesAs(expected *rbacv1.Role) roleContentAssertion { + return func(t *testing.T, role *rbacv1.Role) { + assert.Equal(t, expected.Rules, role.Rules) + } +} + +func (a *storageAssertionImpl) assertRole(namespace, roleName string, contentAssertion ...roleContentAssertion) *storageAssertionImpl { + role := &rbacv1.Role{} + a.assertObject(namespace, roleName, role, func() { + expLabels := map[string]string{ + "provider": "sandbox-sre", + } + assert.Equal(a.t, expLabels, role.Labels) + for _, assertContent := range contentAssertion { + assertContent(a.t, role) + } + }) + return a +} + +func (a *objectsCacheAssertion) assertNumberOfRoles(expectedNumber int) *objectsCacheAssertion { + roles, err := a.listObjects("roles", &rbacv1.Role{}) + require.NoError(a.t, err) + assert.Len(a.t, roles, expectedNumber) + return a +} diff --git a/pkg/cmd/adm/setup_cluster.go b/pkg/cmd/adm/setup_cluster.go new file mode 100644 index 0000000..57731ae --- /dev/null +++ b/pkg/cmd/adm/setup_cluster.go @@ -0,0 +1,58 @@ +package adm + +import ( + "github.com/kubesaw/ksctl/pkg/configuration" +) + +type clusterContext struct { + *setupContext + clusterType configuration.ClusterType +} + +// ensureServiceAccounts reads the list of service accounts definitions and it's permissions. +// It generates SA and roles & roleBindings for them +func ensureServiceAccounts(ctx *clusterContext, objsCache objectsCache) error { + ctx.Printlnf("-> Ensuring ServiceAccounts and its RoleBindings...") + for _, sa := range ctx.sandboxEnvConfig.ServiceAccounts { + + // by default, it should use the sandbox sre namespace. let's keep this empty (if the target namespace is not defined) so it is recognized in the ensureServiceAccount method based on the cluster type it is being applied in + saNamespace := "" + if sa.Namespace != "" { + saNamespace = sa.Namespace + } + + pm := &permissionsManager{ + objectsCache: objsCache, + createSubject: ensureServiceAccount(saNamespace), + subjectBaseName: sa.Name, + } + + if err := pm.ensurePermissions(ctx, sa.PermissionsPerClusterType); err != nil { + return err + } + } + + return nil +} + +// ensureUsers reads the list of users definitions and it's permissions. +// For each of them it generates User and Identity manifests +// If user belongs to a group, then it makes sure that there is a Group manifest with the user name +func ensureUsers(ctx *clusterContext, objsCache objectsCache) error { + ctx.Printlnf("-> Ensuring Users and its RoleBindings...") + + for _, user := range ctx.sandboxEnvConfig.Users { + + permissions := &permissionsManager{ + objectsCache: objsCache, + createSubject: ensureUserIdentityAndGroups(user.ID, user.Groups), + subjectBaseName: user.Name, + } + + if err := permissions.ensurePermissions(ctx, user.PermissionsPerClusterType); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/cmd/adm/setup_cluster_test.go b/pkg/cmd/adm/setup_cluster_test.go new file mode 100644 index 0000000..3868908 --- /dev/null +++ b/pkg/cmd/adm/setup_cluster_test.go @@ -0,0 +1,161 @@ +package adm + +import ( + "fmt" + "testing" + + commontest "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/h2non/gock" + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/configuration" + . "github.com/kubesaw/ksctl/pkg/test" + "github.com/stretchr/testify/require" +) + +func TestEnsureServiceAccounts(t *testing.T) { + t.Run("create permissions for SA base names", func(t *testing.T) { + // given + sandboxEnvConfig := newSandboxEnvironmentConfigWithDefaultClusterAndNamespaces( + ServiceAccounts( + Sa("john", "", + permissionsForAllNamespaces...), + Sa("bob", "", + HostRoleBindings("toolchain-host-operator", Role("restart-deployment"), ClusterRole("view")), + MemberRoleBindings("toolchain-member-operator", Role("restart-deployment"), ClusterRole("view")))), + []assets.User{}) + ctx := newSetupContextWithDefaultFiles(t, sandboxEnvConfig) + cache := objectsCache{} + + for _, clusterType := range configuration.ClusterTypes { + t.Run("for "+clusterType.String()+" cluster", func(t *testing.T) { + // given + clusterCtx := newFakeClusterContext(ctx, clusterType) + t.Cleanup(gock.OffAll) + + // when + err := ensureServiceAccounts(clusterCtx, cache) + + // then + require.NoError(t, err) + + roleNs := fmt.Sprintf("toolchain-%s-operator", clusterType) + saNs := fmt.Sprintf("sandbox-sre-%s", clusterType) + + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertSa(saNs, "john"). + hasRole(roleNs, clusterType.AsSuffix("install-operator"), clusterType.AsSuffix("install-operator-john")). + hasNsClusterRole(roleNs, "view", clusterType.AsSuffix("clusterrole-view-john")) + + if clusterType == configuration.Host { + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertSa(saNs, "john"). + hasRole("openshift-customer-monitoring", clusterType.AsSuffix("restart-deployment"), clusterType.AsSuffix("restart-deployment-john")). + hasNsClusterRole("openshift-customer-monitoring", "edit", clusterType.AsSuffix("clusterrole-edit-john")) + } else { + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertSa(saNs, "john"). + hasRole("codeready-workspaces-operator", clusterType.AsSuffix("register-cluster"), clusterType.AsSuffix("register-cluster-john")). + hasNsClusterRole("codeready-workspaces-operator", "admin", clusterType.AsSuffix("clusterrole-admin-john")) + } + + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertSa(saNs, "bob"). + hasRole(roleNs, clusterType.AsSuffix("restart-deployment"), clusterType.AsSuffix("restart-deployment-bob")). + hasNsClusterRole(roleNs, "view", clusterType.AsSuffix("clusterrole-view-bob")) + }) + } + }) + + t.Run("create SA with the fixed name, in the given namespace, ClusterRoleBinding set, and don't gather the token", func(t *testing.T) { + // given + sandboxEnvConfig := newSandboxEnvironmentConfigWithDefaultClusterAndNamespaces( + ServiceAccounts( + Sa("john", "openshift-customer-monitoring", + HostRoleBindings("toolchain-host-operator", Role("install-operator"), ClusterRole("view")), + HostClusterRoleBindings("cluster-monitoring-view"))), Users()) + ctx := newSetupContextWithDefaultFiles(t, sandboxEnvConfig) + clusterCtx := newFakeClusterContext(ctx, configuration.Host) + t.Cleanup(gock.OffAll) + cache := objectsCache{} + + // when + err := ensureServiceAccounts(clusterCtx, cache) + + // then + require.NoError(t, err) + + inObjectCache(t, ctx.outDir, "host", cache). + assertSa("openshift-customer-monitoring", "john"). + hasRole(commontest.HostOperatorNs, "install-operator-host", "install-operator-john-host"). + hasNsClusterRole(commontest.HostOperatorNs, "view", "clusterrole-view-john-host"). + hasClusterRoleBinding("cluster-monitoring-view", "clusterrole-cluster-monitoring-view-john-host") + }) +} + +func TestUsers(t *testing.T) { + t.Run("ensure users", func(t *testing.T) { + // given + sandboxEnvConfig := newSandboxEnvironmentConfigWithDefaultClusterAndNamespaces( + ServiceAccounts(), + Users( + User("john-user", []string{"12345"}, "crtadmins", + permissionsForAllNamespaces...), + User("bob-crtadmin", []string{"67890"}, "crtadmins", + HostRoleBindings("toolchain-host-operator", Role("restart-deployment"), ClusterRole("view")), + HostClusterRoleBindings("cluster-monitoring-view"), + MemberRoleBindings("toolchain-member-operator", Role("restart-deployment"), ClusterRole("view")), + MemberClusterRoleBindings("cluster-monitoring-view")))) + + ctx := newSetupContextWithDefaultFiles(t, sandboxEnvConfig) + cache := objectsCache{} + + for _, clusterType := range configuration.ClusterTypes { + t.Run("for cluster type: "+clusterType.String(), func(t *testing.T) { + // given + clusterCtx := newFakeClusterContext(ctx, clusterType) + + // when + err := ensureUsers(clusterCtx, cache) + + // then + require.NoError(t, err) + ns := fmt.Sprintf("toolchain-%s-operator", clusterType) + + assertion := inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertUser("john-user"). + hasIdentity("12345"). + belongsToGroups(groups("crtadmins"), extraGroupsUserIsNotPartOf()). + hasRole(ns, clusterType.AsSuffix("install-operator"), clusterType.AsSuffix("install-operator-john-user")). + hasNsClusterRole(ns, "view", clusterType.AsSuffix("clusterrole-view-john-user")) + + if clusterType == configuration.Host { + // "restart-deployment" RoleBinding prefix was renamed to "restart", but the name of the Role stays the same + assertion. + hasRole("openshift-customer-monitoring", clusterType.AsSuffix("restart-deployment"), clusterType.AsSuffix("restart-deployment-john-user")). + // "edit" RoleBinding prefix was renamed to "editor", but the name of the ClusterRole stays the same + hasNsClusterRole("openshift-customer-monitoring", "edit", clusterType.AsSuffix("clusterrole-edit-john-user")) + + } else { + assertion. + hasRole("codeready-workspaces-operator", clusterType.AsSuffix("register-cluster"), clusterType.AsSuffix("register-cluster-john-user")). + hasNsClusterRole("codeready-workspaces-operator", "admin", clusterType.AsSuffix("clusterrole-admin-john-user")) + } + + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertUser("bob-crtadmin"). + hasIdentity("67890"). + belongsToGroups(groups("crtadmins"), extraGroupsUserIsNotPartOf()). + hasRole(ns, clusterType.AsSuffix("restart-deployment"), clusterType.AsSuffix("restart-deployment-bob-crtadmin")). + hasNsClusterRole(ns, "view", clusterType.AsSuffix("clusterrole-view-bob-crtadmin")). + hasClusterRoleBinding("cluster-monitoring-view", clusterType.AsSuffix("clusterrole-cluster-monitoring-view-bob-crtadmin")) + }) + } + }) +} + +func newSandboxEnvironmentConfigWithDefaultClusterAndNamespaces(serviceAccounts []assets.ServiceAccount, users []assets.User) *assets.SandboxEnvironmentConfig { + return NewSandboxEnvironmentConfig( + Clusters(HostServerAPI).AddMember("member-1", Member1ServerAPI), + serviceAccounts, + users) +} diff --git a/pkg/cmd/adm/setup_mock_test.go b/pkg/cmd/adm/setup_mock_test.go new file mode 100644 index 0000000..47d0490 --- /dev/null +++ b/pkg/cmd/adm/setup_mock_test.go @@ -0,0 +1,75 @@ +package adm + +import ( + "fmt" + "os" + "testing" + + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + "github.com/kubesaw/ksctl/pkg/test" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" +) + +const ( + HostServerAPI = "https://api.sandbox.host.openshiftapps.com:6443" + Member1ServerAPI = "https://api.sandbox.member1.openshiftapps.com:6443" + Member2ServerAPI = "https://api.sandbox.member2.openshiftapps.com:6443" +) + +// files part + +func newDefaultFiles(t *testing.T, fakeFiles ...test.FakeFileCreator) assets.FS { + roles := []runtime.Object{installOperatorRole, restartDeploymentRole, editDeploymentRole, registerClusterRole} + + files := test.NewFakeFiles(t, + append(fakeFiles, + test.FakeTemplate("setup/roles/host.yaml", roles...), + test.FakeTemplate("setup/roles/member.yaml", roles...))..., + ) + return files +} + +func createSandboxConfigFile(t *testing.T, dirPrefix string, content []byte) string { //nolint:unparam + configTempDir, err := os.MkdirTemp("", dirPrefix+"-") + require.NoError(t, err) + configFile := fmt.Sprintf("%s/sandbox-config.yaml", configTempDir) + err = os.WriteFile(configFile, content, 0600) + require.NoError(t, err) + return configFile +} + +// setupContext part + +func newSetupContextWithDefaultFiles(t *testing.T, config *assets.SandboxEnvironmentConfig) *setupContext { //nolint:unparam + return newSetupContext(t, config, newDefaultFiles(t)) +} + +func newSetupContext(t *testing.T, config *assets.SandboxEnvironmentConfig, files assets.FS) *setupContext { + fakeTerminal := test.NewFakeTerminal() + fakeTerminal.Tee(os.Stdout) + require.NoError(t, client.AddToScheme()) + temp, err := os.MkdirTemp("", "cli-tests-") + require.NoError(t, err) + return &setupContext{ + Terminal: fakeTerminal, + sandboxEnvConfig: config, + files: files, + setupFlags: setupFlags{ + outDir: temp, + memberRootDir: "member", + hostRootDir: "host", + }, + } +} + +// ClusterContext part + +func newFakeClusterContext(setupContext *setupContext, clusterType configuration.ClusterType) *clusterContext { + return &clusterContext{ + setupContext: setupContext, + clusterType: clusterType, + } +} diff --git a/pkg/cmd/adm/setup_permissions.go b/pkg/cmd/adm/setup_permissions.go new file mode 100644 index 0000000..8d03c8b --- /dev/null +++ b/pkg/cmd/adm/setup_permissions.go @@ -0,0 +1,245 @@ +package adm + +import ( + "fmt" + "sort" + + commonidentity "github.com/codeready-toolchain/toolchain-common/pkg/identity" + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/utils" + userv1 "github.com/openshift/api/user/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +type permissionsManager struct { + objectsCache + createSubject newSubjectFunc + subjectBaseName string +} + +type newSubjectFunc func(ctx *clusterContext, objsCache objectsCache, subjectBaseName, targetNamespace string, labels map[string]string) (rbacv1.Subject, error) + +// ensurePermissions creates/updates Subject, Role, and RoleBinding for all permissions defined for the cluster type +func (m *permissionsManager) ensurePermissions(ctx *clusterContext, roleBindingsPerClusterType assets.PermissionsPerClusterType) error { + // check if there are any permissions set for this cluster type + if _, ok := roleBindingsPerClusterType[ctx.clusterType.String()]; !ok { + return nil + } + + // go through all roleBindings for the cluster type + for _, roleBindings := range roleBindingsPerClusterType[ctx.clusterType.String()].RoleBindings { + + if roleBindings.Namespace == "" { + return fmt.Errorf("the namespace name is not defined for one of the role bindings in the cluster type '%s'", ctx.clusterType) + } + + // ensure RoleBindings with Roles + for _, role := range roleBindings.Roles { + if err := m.ensurePermission(ctx, role, roleBindings.Namespace, "Role", newRoleBindingConstructor()); err != nil { + return err + } + } + + // ensure RoleBindings with ClusterRoles + for _, clusterRole := range roleBindings.ClusterRoles { + if err := m.ensurePermission(ctx, clusterRole, roleBindings.Namespace, "ClusterRole", newRoleBindingConstructor()); err != nil { + return err + } + } + } + for _, clusterRole := range roleBindingsPerClusterType[ctx.clusterType.String()].ClusterRoleBindings.ClusterRoles { + if err := m.ensurePermission(ctx, clusterRole, "", "ClusterRole", newClusterRoleBindingConstructor()); err != nil { + return err + } + } + + return nil +} + +// ensurePermission generates Subject, Role, and RoleBinding for the given permission, namespace and role kind +func (m *permissionsManager) ensurePermission(ctx *clusterContext, roleName, targetNamespace, roleKind string, newBinding bindingConstructor) error { + grantedRoleName := roleName + + var roleBindingName string + if roleKind == "Role" { + // if it is Role, then make sure that it exists in the namespace + exists, createdRoleName, err := ensureRole(ctx, m.objectsCache, roleName, targetNamespace) + if err != nil || !exists { + return err + } + grantedRoleName = createdRoleName + + roleBindingName = fmt.Sprintf("%s-%s-%s", roleName, m.subjectBaseName, ctx.clusterType) + } else { + // ClusterRole is not managed by sandbox-sre and should already exist in the cluster + + // create RoleBinding name with the prefix clusterrole- so we can avoid conflicts with RoleBindings created for Roles + roleBindingName = fmt.Sprintf("clusterrole-%s-%s-%s", roleName, m.subjectBaseName, ctx.clusterType) + } + + // ensure that the subject exists + subject, err := m.createSubject(ctx, m.objectsCache, m.subjectBaseName, sandboxSRENamespace(ctx.clusterType), sreLabelsWithUsername(m.subjectBaseName)) + if err != nil { + return err + } + + // ensure the (Cluster)RoleBinding + binding := newBinding(targetNamespace, roleBindingName, subject, grantedRoleName, roleKind, sreLabels()) + return m.storeObject(ctx, binding) +} + +type bindingConstructor func(namespace, name string, subject rbacv1.Subject, roleName, roleKind string, labels map[string]string) runtimeclient.Object + +func newRoleBindingConstructor() bindingConstructor { + return func(namespace, name string, subject rbacv1.Subject, roleName, roleKind string, labels map[string]string) runtimeclient.Object { + return newRoleBinding(namespace, name, subject, roleName, roleKind, labels) + } +} + +func newRoleBinding(namespace, name string, subject rbacv1.Subject, roleName, roleKind string, labels map[string]string) runtimeclient.Object { + return &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + Labels: labels, + }, + Subjects: []rbacv1.Subject{subject}, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: roleKind, + Name: roleName, + }, + } +} + +func newClusterRoleBindingConstructor() bindingConstructor { + return func(_, name string, subject rbacv1.Subject, roleName, roleKind string, labels map[string]string) runtimeclient.Object { + return newClusterRoleBinding(name, subject, roleName, roleKind, labels) + } +} + +func newClusterRoleBinding(name string, subject rbacv1.Subject, roleName, roleKind string, labels map[string]string) runtimeclient.Object { + return &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + Subjects: []rbacv1.Subject{subject}, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: roleKind, + Name: roleName, + }, + } +} + +var ignoreExtraneousAnnotations = map[string]string{"argocd.argoproj.io/compare-options": "IgnoreExtraneous"} + +// ensureServiceAccount ensures that the ServiceAccount exists +func ensureServiceAccount(saNamespace string) newSubjectFunc { + return func(ctx *clusterContext, cache objectsCache, subjectName, targetNamespace string, labels map[string]string) (rbacv1.Subject, error) { + if saNamespace != "" { + targetNamespace = saNamespace + } + if targetNamespace == "" { + return rbacv1.Subject{}, fmt.Errorf("the SA %s doesn't have any namespace set but requires a ClusterRoleBinding - you need to specify the target namespace of the SA", subjectName) + } + + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: targetNamespace, + Name: subjectName, + Labels: labels, + Annotations: ignoreExtraneousAnnotations, + }, + } + + if err := cache.storeObject(ctx, sa); err != nil { + return rbacv1.Subject{}, err + } + + return rbacv1.Subject{ + Name: subjectName, + Namespace: targetNamespace, + Kind: "ServiceAccount", + }, nil + } +} + +// ensureUserIdentityAndGroups ensures that all - User, Identity, IdentityMapping, and Group manifests - exist +func ensureUserIdentityAndGroups(IDs []string, groups []string) newSubjectFunc { + return func(ctx *clusterContext, cache objectsCache, subjectBaseName, targetNamespace string, labels map[string]string) (rbacv1.Subject, error) { + // create user + user := &userv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: subjectBaseName, + Labels: labels, + Annotations: ignoreExtraneousAnnotations, + }, + } + if err := cache.storeObject(ctx, user); err != nil { + return rbacv1.Subject{}, err + } + if err := ensureGroupsForUser(ctx, cache, subjectBaseName, groups...); err != nil { + return rbacv1.Subject{}, err + } + + // Create identities and identity mappings + for _, id := range IDs { + + ins := commonidentity.NewIdentityNamingStandard(id, "DevSandbox") + + // create identity + identity := &userv1.Identity{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + Annotations: ignoreExtraneousAnnotations, + }, + } + + ins.ApplyToIdentity(identity) + + if err := cache.storeObject(ctx, identity); err != nil { + return rbacv1.Subject{}, err + } + } + return rbacv1.Subject{ + Name: subjectBaseName, + Kind: "User", + }, nil + } +} + +// ensureGroupsForUser ensures that all given Groups exist and that the user is listed in all of them, but not in other groups +func ensureGroupsForUser(ctx *clusterContext, cache objectsCache, user string, groups ...string) error { + for _, groupName := range groups { + group := &userv1.Group{ + ObjectMeta: metav1.ObjectMeta{ + Name: groupName, + Labels: sreLabels(), + }, + Users: []string{user}, + } + if err := cache.ensureObject(ctx, group, func(object runtimeclient.Object) (bool, error) { + existing, ok := object.(*userv1.Group) + if !ok { + return false, fmt.Errorf("object %s is not of the type of Group", object.GetName()) + } + // if it already contains the user, then continue with the next group + if utils.Contains(existing.Users, user) { + return false, nil + } + // add user to the group and update it + existing.Users = append(existing.Users, user) + sort.Strings(existing.Users) + return true, nil + + }); err != nil { + return err + } + } + return nil +} diff --git a/pkg/cmd/adm/setup_permissions_test.go b/pkg/cmd/adm/setup_permissions_test.go new file mode 100644 index 0000000..5f41338 --- /dev/null +++ b/pkg/cmd/adm/setup_permissions_test.go @@ -0,0 +1,208 @@ +package adm + +import ( + "fmt" + "testing" + + commontest "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + . "github.com/kubesaw/ksctl/pkg/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var permissionsForAllNamespaces = []PermissionsPerClusterTypeModifier{ + HostRoleBindings("toolchain-host-operator", Role("install-operator"), ClusterRole("view")), + HostRoleBindings("openshift-customer-monitoring", Role("restart-deployment"), ClusterRole("edit")), + HostRoleBindings("openshift-customer-monitoring", Role("edit-deployment"), ClusterRole("edit-secrets")), + MemberRoleBindings("toolchain-member-operator", Role("install-operator"), ClusterRole("view")), + MemberRoleBindings("codeready-workspaces-operator", Role("register-cluster"), ClusterRole("admin")), +} + +func TestEnsurePermissionsInNamespaces(t *testing.T) { + // given + config := newSandboxEnvironmentConfigWithDefaultClusterAndNamespaces([]assets.ServiceAccount{}, []assets.User{}) + + t.Run("create permissions", func(t *testing.T) { + // given + permissionsPerClusterTypes := NewPermissionsPerClusterType(permissionsForAllNamespaces...) + + for _, clusterType := range configuration.ClusterTypes { + t.Run("for cluster type: "+clusterType.String(), func(t *testing.T) { + permManager, ctx := newPermissionsManager(t, clusterType, config) + + // when + err := permManager.ensurePermissions(ctx, permissionsPerClusterTypes) + + // then + require.NoError(t, err) + roleNs := fmt.Sprintf("toolchain-%s-operator", clusterType) + saNs := fmt.Sprintf("sandbox-sre-%s", clusterType) + + inObjectCache(t, ctx.outDir, clusterType.String(), permManager.objectsCache). + assertSa(saNs, "john"). + hasRole(roleNs, clusterType.AsSuffix("install-operator"), clusterType.AsSuffix("install-operator-john")). + hasNsClusterRole(roleNs, "view", clusterType.AsSuffix("clusterrole-view-john")) + + if clusterType == configuration.Host { + inObjectCache(t, ctx.outDir, clusterType.String(), permManager.objectsCache). + assertSa(saNs, "john"). + hasRole("openshift-customer-monitoring", clusterType.AsSuffix("restart-deployment"), clusterType.AsSuffix("restart-deployment-john")). + hasRole("openshift-customer-monitoring", clusterType.AsSuffix("edit-deployment"), clusterType.AsSuffix("edit-deployment-john")). + hasNsClusterRole("openshift-customer-monitoring", "edit", clusterType.AsSuffix("clusterrole-edit-john")). + hasNsClusterRole("openshift-customer-monitoring", "edit-secrets", clusterType.AsSuffix("clusterrole-edit-secrets-john")) + } else { + inObjectCache(t, ctx.outDir, clusterType.String(), permManager.objectsCache). + assertSa(saNs, "john"). + hasRole("codeready-workspaces-operator", clusterType.AsSuffix("register-cluster"), clusterType.AsSuffix("register-cluster-john")). + hasNsClusterRole("codeready-workspaces-operator", "admin", clusterType.AsSuffix("clusterrole-admin-john")) + } + }) + } + }) + + t.Run("if there is no record for the member cluster type, then skip", func(t *testing.T) { + // given + perms := NewPermissionsPerClusterType(HostRoleBindings(commontest.HostOperatorNs, Role("install-operator"), ClusterRole("view"))) + permissionsManager, ctx := newPermissionsManager(t, configuration.Member, config) + + // when + err := permissionsManager.ensurePermissions(ctx, perms) + + // then + require.NoError(t, err) + assertNoClusterTypeEntry(t, ctx.outDir, configuration.Member) + }) +} + +func TestEnsureServiceAccount(t *testing.T) { + + labels := map[string]string{ + "provider": "sandbox-sre", + "username": "john", + } + + t.Run("create SA", func(t *testing.T) { + // given + ctx := newFakeClusterContext(newSetupContextWithDefaultFiles(t, nil), configuration.Host) + cache := objectsCache{} + + // when + subject, err := ensureServiceAccount("")( + ctx, cache, "john", "sandbox-sre-host", labels) + + // then + require.NoError(t, err) + inObjectCache(t, ctx.outDir, "host", cache). + assertSa("sandbox-sre-host", "john") + assert.Equal(t, "ServiceAccount", subject.Kind) + assert.Equal(t, "john", subject.Name) + assert.Equal(t, "sandbox-sre-host", subject.Namespace) + }) + + t.Run("create SA in the given namespace", func(t *testing.T) { + // given + ctx := newFakeClusterContext(newSetupContextWithDefaultFiles(t, nil), configuration.Host) + cache := objectsCache{} + + // when + subject, err := ensureServiceAccount("openshift-customer-monitoring")( + ctx, cache, "john", "sandbox-sre-host", labels) + + // then + require.NoError(t, err) + inObjectCache(t, ctx.outDir, "host", cache). + assertSa("openshift-customer-monitoring", "john") + assert.Equal(t, "ServiceAccount", subject.Kind) + assert.Equal(t, "john", subject.Name) + assert.Equal(t, "openshift-customer-monitoring", subject.Namespace) + }) +} + +func TestEnsureUserAndIdentity(t *testing.T) { + labels := map[string]string{ + "provider": "sandbox-sre", + "username": "john-crtadmin", + } + require.NoError(t, client.AddToScheme()) + + t.Run("create user, multiple identity & groups", func(t *testing.T) { + // given + ctx := newFakeClusterContext(newSetupContextWithDefaultFiles(t, nil), configuration.Host) + cache := objectsCache{} + + // when + subject, err := ensureUserIdentityAndGroups([]string{"12345", "abc:19944:FZZ"}, []string{"crtadmins", "cooladmins"})(ctx, cache, "john-crtadmin", commontest.HostOperatorNs, labels) + + // then + require.NoError(t, err) + inObjectCache(t, ctx.outDir, "host", cache). + assertUser("john-crtadmin"). + hasIdentity("12345"). + hasIdentity("abc:19944:FZZ"). + belongsToGroups(groups("crtadmins", "cooladmins"), extraGroupsUserIsNotPartOf()) + assert.Equal(t, "User", subject.Kind) + assert.Equal(t, "john-crtadmin", subject.Name) + assert.Empty(t, subject.Namespace) + }) + + t.Run("don't create any group", func(t *testing.T) { + // given + ctx := newFakeClusterContext(newSetupContextWithDefaultFiles(t, nil), configuration.Host) + cache := objectsCache{} + + // when + _, err := ensureUserIdentityAndGroups([]string{"12345"}, []string{})(ctx, cache, "john-crtadmin", commontest.HostOperatorNs, labels) + + // then + require.NoError(t, err) + inObjectCache(t, ctx.outDir, "host", cache). + assertUser("john-crtadmin"). + hasIdentity("12345"). + belongsToGroups(groups(), extraGroupsUserIsNotPartOf()) + }) +} + +func TestEnsureGroupsForUser(t *testing.T) { + require.NoError(t, client.AddToScheme()) + + t.Run("when creating group(s)", func(t *testing.T) { + // given + ctx := newFakeClusterContext(newSetupContextWithDefaultFiles(t, nil), configuration.Host) + cache := objectsCache{} + + // when + err := ensureGroupsForUser(ctx, cache, "cool-user", "crtadmins") + + // then + require.NoError(t, err) + inObjectCache(t, ctx.outDir, "host", cache). + assertThatGroupHasUsers("crtadmins", "cool-user") + + t.Run("another user with multiple groups", func(t *testing.T) { + // when + err := ensureGroupsForUser(ctx, cache, "another-user", "cool-group", "crtadmins", "another-group") + + // then + require.NoError(t, err) + inObjectCache(t, ctx.outDir, "host", cache). + assertThatGroupHasUsers("crtadmins", "cool-user", "another-user"). + assertThatGroupHasUsers("cool-group", "another-user"). + assertThatGroupHasUsers("another-group", "another-user") + }) + }) +} + +func newPermissionsManager(t *testing.T, clusterType configuration.ClusterType, config *assets.SandboxEnvironmentConfig) (permissionsManager, *clusterContext) { // nolint:unparam + ctx := newSetupContextWithDefaultFiles(t, config) + clusterCtx := newFakeClusterContext(ctx, clusterType) + cache := objectsCache{} + + return permissionsManager{ + objectsCache: cache, + subjectBaseName: "john", + createSubject: ensureServiceAccount(""), + }, clusterCtx +} diff --git a/pkg/cmd/adm/setup_roles_manager.go b/pkg/cmd/adm/setup_roles_manager.go new file mode 100644 index 0000000..5814b99 --- /dev/null +++ b/pkg/cmd/adm/setup_roles_manager.go @@ -0,0 +1,53 @@ +package adm + +import ( + "fmt" + + "github.com/kubesaw/ksctl/pkg/assets" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +func getRole(ctx *clusterContext, roleName string) (*rbacv1.Role, error) { + // get all roles for the cluster type + roles, err := assets.GetRoles(ctx.files, ctx.clusterType) + if err != nil { + return nil, err + } + + for _, roleObject := range roles { + + if roleObject.GetName() == roleName { + // cast to Unstructured so we can then convert it to Role object + unstructuredRole, ok := roleObject.(*unstructured.Unstructured) + if !ok { + return nil, fmt.Errorf("unable to cast Role to Unstructured object '%+v'", roleObject) + } + role := &rbacv1.Role{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredRole.Object, role); err != nil { + return nil, err + } + return role, nil + } + } + return nil, nil +} + +// ensureRole generates the given role for the given namespace +func ensureRole(ctx *clusterContext, cache objectsCache, roleName, namespace string) (bool, string, error) { + roleNameToBeCreated := ctx.clusterType.AsSuffix(roleName) + role, err := getRole(ctx, roleName) + if err != nil { + return false, roleNameToBeCreated, err + } + if role == nil { + return false, roleNameToBeCreated, fmt.Errorf("there is no such role with the name '%s' defined", roleName) + } + + roleToBeCreated := role.DeepCopy() + roleToBeCreated.SetNamespace(namespace) + roleToBeCreated.SetName(roleNameToBeCreated) + roleToBeCreated.SetLabels(sreLabels()) + return true, roleNameToBeCreated, cache.storeObject(ctx, roleToBeCreated) +} diff --git a/pkg/cmd/adm/setup_roles_manager_test.go b/pkg/cmd/adm/setup_roles_manager_test.go new file mode 100644 index 0000000..e1dc095 --- /dev/null +++ b/pkg/cmd/adm/setup_roles_manager_test.go @@ -0,0 +1,220 @@ +package adm + +import ( + "testing" + + commontest "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/configuration" + . "github.com/kubesaw/ksctl/pkg/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + installOperatorRole = newRole("install-operator", newRule("operators.coreos.com", "catalogsources", "create")) + restartDeploymentRole = newRole("restart-deployment", newRule("apps", "deployment", "update")) + editDeploymentRole = newRole("edit-deployment", newRule("apps", "deployment", "edit")) + configureComponentRole = newRole("configure-component", newRule("monitoring.coreos.com", "prometheuses", "create")) + registerClusterRole = newRole("register-cluster", newRule("toolchain.dev.openshift.com", "usersignups", "update")) +) + +func TestGetRole(t *testing.T) { + // given + files := NewFakeFiles(t, + FakeTemplate("setup/roles/host.yaml", installOperatorRole), + FakeTemplate("setup/roles/member.yaml", restartDeploymentRole, registerClusterRole)) + ctx := newSetupContext(t, &assets.SandboxEnvironmentConfig{}, files) + + t.Run("for host cluster type", func(t *testing.T) { + // given + clusterCtx := newFakeClusterContext(ctx, configuration.Host) + + // when + role, err := getRole(clusterCtx, "install-operator") + + // then + require.NoError(t, err) + assert.Equal(t, installOperatorRole, role) + + t.Run("non-existing role", func(t *testing.T) { + // when + role, err := getRole(clusterCtx, "does-not-exist") + + // then + require.NoError(t, err) + assert.Nil(t, role) + }) + }) + + t.Run("for member cluster type", func(t *testing.T) { + // given + clusterCtx := newFakeClusterContext(ctx, configuration.Member) + + // when + restartRole, err := getRole(clusterCtx, "restart-deployment") + require.NoError(t, err) + registerRole, err := getRole(clusterCtx, "register-cluster") + require.NoError(t, err) + + // then + assert.Equal(t, restartDeploymentRole, restartRole) + assert.Equal(t, registerClusterRole, registerRole) + + t.Run("non-existing role", func(t *testing.T) { + // when + role, err := getRole(clusterCtx, "does-not-exist") + + // then + require.NoError(t, err) + assert.Nil(t, role) + }) + }) + + t.Run("fail for non-existing cluster type", func(t *testing.T) { + // given + clusterCtx := newFakeClusterContext(ctx, "fail") + + // when + restartRole, err := getRole(clusterCtx, "restart-deployment") + + // then + require.Error(t, err) + assert.Nil(t, restartRole) + }) +} + +func TestEnsureRole(t *testing.T) { + // given + files := NewFakeFiles(t, + FakeTemplate("setup/roles/host.yaml", installOperatorRole), + FakeTemplate("setup/roles/member.yaml", installOperatorRole, restartDeploymentRole, configureComponentRole, registerClusterRole)) + + t.Run("create install-operator role for host", func(t *testing.T) { + // given + ctx := newSetupContext(t, &assets.SandboxEnvironmentConfig{}, files) + hostCtx := newFakeClusterContext(ctx, configuration.Host) + memberCtx := newFakeClusterContext(ctx, configuration.Member) + cache := objectsCache{} + + // when + created, roleName, err := ensureRole(hostCtx, cache, "install-operator", commontest.HostOperatorNs) + + // then + require.NoError(t, err) + assert.True(t, created) + assert.Equal(t, "install-operator-host", roleName) + inObjectCache(t, ctx.outDir, "host", cache). + assertNumberOfRoles(1). + assertRole(commontest.HostOperatorNs, roleName, hasSameRulesAs(installOperatorRole)) + + t.Run("create install-operator role in another namespace", func(t *testing.T) { + // when + created, roleName, err := ensureRole(hostCtx, cache, "install-operator", "monitoring") + + // then + require.NoError(t, err) + assert.True(t, created) + assert.Equal(t, "install-operator-host", roleName) + inObjectCache(t, ctx.outDir, "host", cache). + assertNumberOfRoles(2). + assertRole(commontest.HostOperatorNs, "install-operator-host", hasSameRulesAs(installOperatorRole)). + assertRole("monitoring", "install-operator-host", hasSameRulesAs(installOperatorRole)) + + t.Run("create install-operator role in the same namespace but for member cluster type", func(t *testing.T) { + // when + created, roleName, err := ensureRole(memberCtx, cache, "install-operator", "monitoring") + + // then + require.NoError(t, err) + assert.True(t, created) + assert.Equal(t, "install-operator-member", roleName) + inObjectCache(t, ctx.outDir, "host", cache). + assertNumberOfRoles(2). + assertRole(commontest.HostOperatorNs, "install-operator-host", hasSameRulesAs(installOperatorRole)). + assertRole("monitoring", "install-operator-host", hasSameRulesAs(installOperatorRole)) + inObjectCache(t, ctx.outDir, "member", cache). + assertNumberOfRoles(1). + assertRole("monitoring", "install-operator-member", hasSameRulesAs(installOperatorRole)) + + t.Run("running for non-existing role fails", func(t *testing.T) { + // when + created, _, err := ensureRole(hostCtx, cache, "fail", "failing") + + // then + require.Error(t, err) + assert.False(t, created) + inObjectCache(t, ctx.outDir, "host", cache). + assertNumberOfRoles(2) + }) + }) + }) + }) + + t.Run("create restart-deployment role for member", func(t *testing.T) { + // given + ctx := newSetupContext(t, &assets.SandboxEnvironmentConfig{}, files) + memberCtx := newFakeClusterContext(ctx, configuration.Member) + cache := objectsCache{} + + // when + created, roleName, err := ensureRole(memberCtx, cache, "restart-deployment", commontest.MemberOperatorNs) + + // then + require.NoError(t, err) + assert.True(t, created) + assert.Equal(t, "restart-deployment-member", roleName) + inObjectCache(t, ctx.outDir, "member", cache). + assertNumberOfRoles(1). + assertRole(commontest.MemberOperatorNs, "restart-deployment-member", hasSameRulesAs(restartDeploymentRole)) + + t.Run("create configure-component role in the same namespace", func(t *testing.T) { + // when + created, roleName, err := ensureRole(memberCtx, cache, "configure-component", commontest.MemberOperatorNs) + + // then + require.NoError(t, err) + assert.True(t, created) + assert.Equal(t, "configure-component-member", roleName) + inObjectCache(t, ctx.outDir, "member", cache). + assertNumberOfRoles(2). + assertRole(commontest.MemberOperatorNs, "restart-deployment-member", hasSameRulesAs(restartDeploymentRole)). + assertRole(commontest.MemberOperatorNs, "configure-component-member", hasSameRulesAs(configureComponentRole)) + + t.Run("running for same restart-deployment in the same namespace doesn't have any effect", func(t *testing.T) { + // when + created, roleName, err := ensureRole(memberCtx, cache, "restart-deployment", commontest.MemberOperatorNs) + + // then + require.NoError(t, err) + assert.True(t, created) + assert.Equal(t, "restart-deployment-member", roleName) + inObjectCache(t, ctx.outDir, "member", cache). + assertNumberOfRoles(2) + }) + }) + }) +} + +func newRule(apiGroup, resource, verb string) rbacv1.PolicyRule { + return rbacv1.PolicyRule{ + APIGroups: []string{apiGroup}, + Resources: []string{resource}, + Verbs: []string{verb}, + } +} + +func newRole(name string, rules ...rbacv1.PolicyRule) *rbacv1.Role { + return &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + TypeMeta: metav1.TypeMeta{ + APIVersion: rbacv1.SchemeGroupVersion.String(), + Kind: "Role", + }, + Rules: rules, + } +} diff --git a/pkg/cmd/adm/setup_test.go b/pkg/cmd/adm/setup_test.go new file mode 100644 index 0000000..86e1f2a --- /dev/null +++ b/pkg/cmd/adm/setup_test.go @@ -0,0 +1,351 @@ +package adm + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + commontest "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + . "github.com/kubesaw/ksctl/pkg/test" + userv1 "github.com/openshift/api/user/v1" + uuid "github.com/satori/go.uuid" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestSetup(t *testing.T) { + // given + require.NoError(t, client.AddToScheme()) + sandboxEnvConfig := NewSandboxEnvironmentConfig( + Clusters(HostServerAPI). + AddMember("member1", Member1ServerAPI). + AddMember("member2", Member2ServerAPI), + ServiceAccounts( + Sa("john", "", + HostRoleBindings("toolchain-host-operator", Role("install-operator"), ClusterRole("admin")), + MemberRoleBindings("toolchain-member-operator", Role("install-operator"), ClusterRole("admin"))), + Sa("bob", "", + HostRoleBindings("toolchain-host-operator", Role("restart-deployment"), ClusterRole("edit")), + MemberRoleBindings("toolchain-member-operator", Role("restart-deployment"), ClusterRole("edit")))), + Users( + User("john-user", []string{"12345"}, "crtadmins-view", + HostRoleBindings("toolchain-host-operator", Role("register-cluster"), ClusterRole("edit")), + MemberRoleBindings("toolchain-member-operator", Role("register-cluster"), ClusterRole("edit"))), + User("bob-crtadmin", []string{"67890"}, "crtadmins-exec", + HostRoleBindings("toolchain-host-operator", Role("restart-deployment"), ClusterRole("admin")), + MemberRoleBindings("toolchain-member-operator", Role("restart-deployment"), ClusterRole("admin"))))) + + sandboxEnvConfigContent, err := yaml.Marshal(sandboxEnvConfig) + require.NoError(t, err) + + configFile := createSandboxConfigFile(t, "sandbox.host.openshiftapps.com", sandboxEnvConfigContent) + files := newDefaultFiles(t) + + t.Run("all created", func(t *testing.T) { + // given + outTempDir, err := os.MkdirTemp("", "setup-cli-test-") + require.NoError(t, err) + term := NewFakeTerminalWithResponse("Y") + term.Tee(os.Stdout) + flags := newSetupFlags(outDir(outTempDir), sandboxConfigFile(configFile)) + + // when + err = Setup(term, files, flags) + + // then + require.NoError(t, err) + verifyFiles(t, flags) + }) + + t.Run("in single-cluster mode", func(t *testing.T) { + // given + outTempDir, err := os.MkdirTemp("", "setup-cli-test-") + require.NoError(t, err) + term := NewFakeTerminalWithResponse("Y") + term.Tee(os.Stdout) + flags := newSetupFlags(outDir(outTempDir), sandboxConfigFile(configFile), singleCluster()) + + // when + err = Setup(term, files, flags) + + // then + require.NoError(t, err) + verifyFiles(t, flags) + }) + + t.Run("in custom host root directory", func(t *testing.T) { + // given + outTempDir, err := os.MkdirTemp("", "setup-cli-test-") + require.NoError(t, err) + term := NewFakeTerminalWithResponse("Y") + term.Tee(os.Stdout) + flags := newSetupFlags(outDir(outTempDir), sandboxConfigFile(configFile), hostRootDir("host-cluster")) + + // when + err = Setup(term, files, flags) + + // then + require.NoError(t, err) + verifyFiles(t, flags) + }) + + t.Run("in custom member root directory", func(t *testing.T) { + // given + outTempDir, err := os.MkdirTemp("", "setup-cli-test-") + require.NoError(t, err) + term := NewFakeTerminalWithResponse("Y") + term.Tee(os.Stdout) + flags := newSetupFlags(outDir(outTempDir), sandboxConfigFile(configFile), memberRootDir("member-clusters")) + + // when + err = Setup(term, files, flags) + + // then + require.NoError(t, err) + verifyFiles(t, flags) + }) + + t.Run("previous deleted", func(t *testing.T) { + // given + outTempDir, err := os.MkdirTemp("", "setup-cli-test-") + require.NoError(t, err) + storeDummySA(t, outTempDir) + term := NewFakeTerminalWithResponse("Y") + term.Tee(os.Stdout) + flags := newSetupFlags(outDir(outTempDir), sandboxConfigFile(configFile)) + + // when + err = Setup(term, files, flags) + + // then + require.NoError(t, err) + verifyFiles(t, flags) + }) + + t.Run("if out dir doesn't exist then it creates", func(t *testing.T) { + // given + outTempDir := filepath.Join(os.TempDir(), fmt.Sprintf("setup-cli-test-%s", uuid.NewV4().String())) + term := NewFakeTerminalWithResponse("Y") + term.Tee(os.Stdout) + flags := newSetupFlags(outDir(outTempDir), sandboxConfigFile(configFile)) + + // when + err = Setup(term, files, flags) + + // then + require.NoError(t, err) + verifyFiles(t, flags) + }) + + t.Run("fails for non-existing sandbox-config.yaml file", func(t *testing.T) { + // given + outTempDir, err := os.MkdirTemp("", "setup-cli-test-") + require.NoError(t, err) + term := NewFakeTerminalWithResponse("Y") + term.Tee(os.Stdout) + flags := newSetupFlags(outDir(outTempDir), sandboxConfigFile("does/not/exist")) + + // when + err = Setup(term, files, flags) + + // then + require.Error(t, err) + }) +} + +func storeDummySA(t *testing.T, outDir string) { + ctx := newSetupContextWithDefaultFiles(t, nil) + ctx.outDir = outDir + sa := &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{ + Kind: "User", + APIVersion: userv1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "dummy-namespace", + Name: "dummy-name", + }, + } + err := writeManifest(ctx, filePath(filepath.Join(outDir, "dummy"), sa, "ServiceAccount"), sa) + require.NoError(t, err) +} + +func verifyFiles(t *testing.T, flags setupFlags) { + dirEntries, err := os.ReadDir(flags.outDir) + require.NoError(t, err) + var dirNames []string + if !flags.singleCluster { + assert.Len(t, dirEntries, 2) + dirNames = []string{dirEntries[0].Name(), dirEntries[1].Name()} + } else { + assert.Len(t, dirEntries, 3) + dirNames = []string{dirEntries[0].Name(), dirEntries[1].Name(), dirEntries[2].Name()} + } + + for _, clusterType := range configuration.ClusterTypes { + ns := commontest.HostOperatorNs + expectedRootDir := flags.hostRootDir + if clusterType == configuration.Member { + ns = commontest.MemberOperatorNs + expectedRootDir = flags.memberRootDir + } + assert.Contains(t, dirNames, expectedRootDir) + verifyServiceAccounts(t, flags.outDir, expectedRootDir, clusterType, ns) + verifyUsers(t, flags.outDir, expectedRootDir, clusterType, ns, flags.singleCluster) + } +} + +func verifyServiceAccounts(t *testing.T, outDir, expectedRootDir string, clusterType configuration.ClusterType, roleNs string) { + saNs := fmt.Sprintf("sandbox-sre-%s", clusterType) + + inKStructure(t, outDir, expectedRootDir). + assertSa(saNs, "john"). + hasRole(roleNs, clusterType.AsSuffix("install-operator"), clusterType.AsSuffix("install-operator-john")). + hasNsClusterRole(roleNs, "admin", clusterType.AsSuffix("clusterrole-admin-john")) + + inKStructure(t, outDir, expectedRootDir). + assertSa(saNs, "bob"). + hasRole(roleNs, clusterType.AsSuffix("restart-deployment"), clusterType.AsSuffix("restart-deployment-bob")). + hasNsClusterRole(roleNs, "edit", clusterType.AsSuffix("clusterrole-edit-bob")) +} + +func verifyUsers(t *testing.T, outDir, expectedRootDir string, clusterType configuration.ClusterType, ns string, singleCluster bool) { + rootDir := expectedRootDir + if singleCluster { + rootDir = "base" + } + + inKStructure(t, outDir, rootDir). + assertUser("john-user"). + hasIdentity("12345"). + belongsToGroups(groups("crtadmins-view"), extraGroupsUserIsNotPartOf("crtadmins-exec")) + + storageAssertion := inKStructure(t, outDir, expectedRootDir).storageAssertionImpl + newPermissionAssertion(storageAssertion, "", "john-user", "User"). + hasRole(ns, clusterType.AsSuffix("register-cluster"), clusterType.AsSuffix("register-cluster-john-user")). + hasNsClusterRole(ns, "edit", clusterType.AsSuffix("clusterrole-edit-john-user")) + + inKStructure(t, outDir, rootDir). + assertUser("bob-crtadmin"). + hasIdentity("67890"). + belongsToGroups(groups("crtadmins-exec"), extraGroupsUserIsNotPartOf("crtadmins-view")) + + newPermissionAssertion(storageAssertion, "", "bob-crtadmin", "User"). + hasRole(ns, clusterType.AsSuffix("restart-deployment"), clusterType.AsSuffix("restart-deployment-bob-crtadmin")). + hasNsClusterRole(ns, "admin", clusterType.AsSuffix("clusterrole-admin-bob-crtadmin")) +} + +func createKubeconfigFiles(t *testing.T, contents ...string) []string { + var fileNames []string + for _, content := range contents { + tempFile, err := os.CreateTemp("", "sandbox-sre-kubeconfig-") + require.NoError(t, err) + + err = os.WriteFile(tempFile.Name(), []byte(content), os.FileMode(0755)) + require.NoError(t, err) + + require.NoError(t, tempFile.Close()) + fileNames = append(fileNames, tempFile.Name()) + } + return fileNames +} + +const sandboxKubeconfigContent = ` +apiVersion: v1 +clusters: +- cluster: + server: https://api.sandbox.host.openshiftapps.com:6443 + name: api-sandbox-host-openshiftapps-com:6443 +- cluster: + server: https://api.sandbox.member1.openshiftapps.com:6443 + name: api-sandbox-member1-openshiftapps-com:6443 +contexts: +- context: + cluster: api-sandbox-host-openshiftapps-com:6443 + namespace: toolchain-host-operator + user: dedicatedadmin + name: host +- context: + cluster: api-sandbox-member1-openshiftapps-com:6443 + namespace: toolchain-member-operator + user: dedicatedadmin + name: member1 +current-context: host +kind: Config +preferences: {} +users: +- name: dedicatedadmin + user: + token: my-cool-token +` + +const sandboxKubeconfigContentMember2 = ` +apiVersion: v1 +clusters: +- cluster: + server: https://api.sandbox.member2.openshiftapps.com:6443 + name: api-sandbox-member2-openshiftapps-com:6443 +contexts: +- context: + cluster: api-sandbox-member2-openshiftapps-com:6443 + namespace: toolchain-member-operator + user: dedicatedadmin + name: member2 +current-context: member2 +kind: Config +preferences: {} +users: +- name: dedicatedadmin + user: + token: my-cool-token +` + +type setupFlagsOption func(*setupFlags) + +func newSetupFlags(setupFlagsOptions ...setupFlagsOption) setupFlags { + flags := setupFlags{ + hostRootDir: "host", + memberRootDir: "member", + } + for _, applyOption := range setupFlagsOptions { + applyOption(&flags) + } + return flags +} + +func sandboxConfigFile(configName string) setupFlagsOption { + return func(flags *setupFlags) { + flags.sandboxConfigFile = configName + } +} + +func outDir(outDir string) setupFlagsOption { + return func(flags *setupFlags) { + flags.outDir = outDir + } +} + +func hostRootDir(hostRootDir string) setupFlagsOption { + return func(flags *setupFlags) { + flags.hostRootDir = hostRootDir + } +} + +func memberRootDir(memberRootDir string) setupFlagsOption { + return func(flags *setupFlags) { + flags.memberRootDir = memberRootDir + } +} + +func singleCluster() setupFlagsOption { + return func(flags *setupFlags) { + flags.singleCluster = true + } +} diff --git a/pkg/cmd/adm/setup_util.go b/pkg/cmd/adm/setup_util.go new file mode 100644 index 0000000..dd11550 --- /dev/null +++ b/pkg/cmd/adm/setup_util.go @@ -0,0 +1,210 @@ +package adm + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/ghodss/yaml" + "github.com/kubesaw/ksctl/pkg/configuration" + "github.com/kubesaw/ksctl/pkg/utils" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/kubernetes/scheme" + ktypes "sigs.k8s.io/kustomize/api/types" + + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +type objectsCache map[string]runtimeclient.Object + +func (c objectsCache) storeObject(ctx *clusterContext, obj runtimeclient.Object) error { + return c.ensureObject(ctx, obj, nil) +} + +func (c objectsCache) ensureObject(ctx *clusterContext, toEnsure runtimeclient.Object, updateExisting func(runtimeclient.Object) (bool, error)) error { + obj := toEnsure.DeepCopyObject().(runtimeclient.Object) + gvks, _, err := scheme.Scheme.ObjectKinds(obj) + if err != nil { + return err + } + if len(gvks) > 1 { + return fmt.Errorf("multiple versions of a single GK not supported but found multiple for object %v", obj) + } + obj.GetObjectKind().SetGroupVersionKind(gvks[0]) + path, theOtherTypePath, basePath, err := filePaths(ctx, obj) + if err != nil { + return err + } + if ctx.singleCluster { + if _, exists := c[basePath]; exists { + path = basePath + } else if existing, exists := c[theOtherTypePath]; exists { + c[basePath] = existing + delete(c, theOtherTypePath) + path = basePath + } + } + if existing, exists := c[path]; exists { + if updateExisting == nil { + // "the file already exists and is not supposed to be updated + return nil + } + toUpdate := existing.DeepCopyObject().(runtimeclient.Object) + if modified, err := updateExisting(toUpdate); !modified || err != nil { + return err + } + c[path] = toUpdate + return nil + } + c[path] = obj + return nil +} + +func (c objectsCache) writeManifests(ctx *setupContext) error { + for path, object := range c { + if err := writeManifest(ctx, path, object); err != nil { + return err + } + } + return nil +} + +func writeManifest(ctx *setupContext, filePath string, obj runtimeclient.Object) error { + dirPath := filepath.Dir(filePath) + if err := os.MkdirAll(dirPath, 0744); err != nil { + return err + } + + content, err := yaml.Marshal(obj) + if err != nil { + return err + } + if err := writeFile(filePath, content); err != nil { + return err + } + if err := ensureKustomization(ctx, dirPath, filepath.Base(filePath)); err != nil { + return err + } + return nil +} + +func filePaths(ctx *clusterContext, obj runtimeclient.Object) (string, string, string, error) { + kind := obj.GetObjectKind().GroupVersionKind() + plural, _ := meta.UnsafeGuessKindToResource(kind) + // if kind is not defined in the given object, then fail + if plural.Resource == "" { + return "", "", "", fmt.Errorf("missing kind in the manifest %s of the type %s", obj.GetName(), reflect.TypeOf(obj).Elem().Name()) + } + if obj.GetName() == "" { + return "", "", "", fmt.Errorf("missing name in the manifest of the type %s", reflect.TypeOf(obj).Elem().Name()) + } + + defaultPath := filePath(rootDir(ctx.setupContext, ctx.clusterType), obj, plural.Resource) + theOtherTypePath := filePath(rootDir(ctx.setupContext, ctx.clusterType.TheOtherType()), obj, plural.Resource) + basePath := filePath(baseDirectory(ctx.outDir), obj, plural.Resource) + + return defaultPath, theOtherTypePath, basePath, nil +} + +func rootDir(ctx *setupContext, clusterType configuration.ClusterType) string { + if clusterType == configuration.Host { + return filepath.Join(ctx.outDir, ctx.hostRootDir) + } + return filepath.Join(ctx.outDir, ctx.memberRootDir) +} + +func filePath(rootDir string, obj runtimeclient.Object, resource string) string { + dirPath := filepath.Join(rootDir, "namespace-scoped", obj.GetNamespace(), strings.ToLower(resource)) + if obj.GetNamespace() == "" { + dirPath = filepath.Join(rootDir, "cluster-scoped", strings.ToLower(resource)) + } + path := filepath.Join(dirPath, fmt.Sprintf("%s.yaml", obj.GetName())) + // make sure that `path` does not contain `:` characters + path = strings.ReplaceAll(path, ":", "-") + return path +} + +const header = `# ---------------------------------------------------------------- +# Generated by cli - DO NOT EDIT +# ---------------------------------------------------------------- + +` + +func writeFile(filePath string, content []byte) error { + // https://github.com/kubernetes/kubernetes/issues/67610 + contentString := strings.ReplaceAll(string(content), "\n creationTimestamp: null", "") + contentString = strings.ReplaceAll(contentString, "\nuser: {}", "") + contentString = fmt.Sprintf("%s%s", header, contentString) + return os.WriteFile(filePath, []byte(contentString), 0600) +} + +func baseDirectory(outDir string) string { + return filepath.Join(outDir, "base") +} + +func ensureKustomization(ctx *setupContext, dirPath, item string) error { + kustomization := &ktypes.Kustomization{ + TypeMeta: ktypes.TypeMeta{ + APIVersion: ktypes.KustomizationVersion, + Kind: ktypes.KustomizationKind, + }, + } + kFilePath := filepath.Join(dirPath, "kustomization.yaml") + if _, err := os.Stat(kFilePath); err != nil && !os.IsNotExist(err) { + return err + } else if err == nil { + file, err := os.ReadFile(kFilePath) + if err != nil { + return err + } + if err := yaml.Unmarshal(file, kustomization); err != nil { + return err + } + } + if utils.Contains(kustomization.Resources, item) { + return nil + } + kustomization.Resources = append(kustomization.Resources, item) + sort.Strings(kustomization.Resources) + content, err := yaml.Marshal(kustomization) + if err != nil { + return err + } + if err := os.MkdirAll(dirPath, 0744); err != nil { + return err + } + if err := writeFile(kFilePath, content); err != nil { + return err + } + parentDir := filepath.Dir(dirPath) + if ctx.outDir == parentDir { + if dirPath == baseDirectory(ctx.outDir) { + return ensureKustomization(ctx, rootDir(ctx, configuration.Member), "../base") + } + return nil + } + return ensureKustomization(ctx, parentDir, filepath.Base(dirPath)) +} + +func sreLabelsWithUsername(username string) map[string]string { + labels := sreLabels() + labels["username"] = username + return labels +} + +func sreLabels() map[string]string { + return map[string]string{ + "provider": "sandbox-sre", + } +} + +func sandboxSRENamespace(clusterType configuration.ClusterType) string { + sandboxSRENamespace := "sandbox-sre-host" + if clusterType == configuration.Member { + sandboxSRENamespace = "sandbox-sre-member" + } + return sandboxSRENamespace +} diff --git a/pkg/cmd/adm/setup_util_test.go b/pkg/cmd/adm/setup_util_test.go new file mode 100644 index 0000000..90d12b1 --- /dev/null +++ b/pkg/cmd/adm/setup_util_test.go @@ -0,0 +1,298 @@ +package adm + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/kubesaw/ksctl/pkg/configuration" + v1 "github.com/openshift/api/user/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/scheme" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestEnsureObject(t *testing.T) { + // given + for _, clusterType := range configuration.ClusterTypes { + t.Run("for cluster type "+clusterType.String(), func(t *testing.T) { + + t.Run("for User object", func(t *testing.T) { + verifyEnsureManifest(t, clusterType, &v1.User{}) + }) + + t.Run("for ServiceAccount object", func(t *testing.T) { + verifyEnsureManifest(t, clusterType, &corev1.ServiceAccount{}) + }) + }) + } +} + +func prepareObjects(t *testing.T, name string, namespace string, object runtimeclient.Object) (runtimeclient.Object, runtimeclient.Object) { + gvks, _, err := scheme.Scheme.ObjectKinds(object) + require.NoError(t, err) + require.Len(t, gvks, 1) + + toBeStored := object.DeepCopyObject().(runtimeclient.Object) + if gvks[0].Kind != "User" { + toBeStored.SetNamespace(namespace) + } + toBeStored.SetName(name) + + expectedWithTypeMeta := toBeStored.DeepCopyObject().(runtimeclient.Object) + expectedWithTypeMeta.GetObjectKind().SetGroupVersionKind(gvks[0]) + + return toBeStored, expectedWithTypeMeta +} + +func verifyEnsureManifest(t *testing.T, clusterType configuration.ClusterType, object runtimeclient.Object) { + for _, namespace := range []string{"johnspace", "second-namespace", ""} { + t.Run("for namespace "+namespace, func(t *testing.T) { + // given + ctx := newSetupContextWithDefaultFiles(t, nil) + cache := objectsCache{} + toBeStored, expected := prepareObjects(t, "john", namespace, object) + + // when + err := cache.ensureObject(newFakeClusterContext(ctx, clusterType), toBeStored, nil) + + // then + require.NoError(t, err) + actual := object.DeepCopyObject().(runtimeclient.Object) + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertObject(toBeStored.GetNamespace(), "john", actual, func() { + assert.Equal(t, expected, actual) + }) + + verifyUpdates(t, newFakeClusterContext(ctx, clusterType), cache, object, toBeStored, expected, clusterType.String()) + + t.Run("second resource", func(t *testing.T) { + // given + toBeStored2, expected2 := prepareObjects(t, "second", namespace, object) + + // when + err := cache.ensureObject(newFakeClusterContext(ctx, clusterType), toBeStored2, nil) + + // then + require.NoError(t, err) + actual := object.DeepCopyObject().(runtimeclient.Object) + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertObject(toBeStored.GetNamespace(), "second", actual, func() { + assert.Equal(t, expected2, actual) + }) + + t.Run("no change when update function fails", func(t *testing.T) { + // when + err := cache.ensureObject(newFakeClusterContext(ctx, clusterType), toBeStored2, func(object runtimeclient.Object) (bool, error) { + object.SetLabels(map[string]string{"dummy-key": "dummy-value"}) + return true, fmt.Errorf("some errror") + }) + + // then + require.Error(t, err) + actual := object.DeepCopyObject().(runtimeclient.Object) + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertObject(toBeStored.GetNamespace(), "second", actual, func() { + assert.Equal(t, expected2, actual) + }) + }) + }) + + t.Run("fails for missing name", func(t *testing.T) { + // given + invalid := expected.DeepCopyObject().(runtimeclient.Object) + invalid.SetName("") + + // when + err := cache.ensureObject(newFakeClusterContext(ctx, clusterType), invalid.DeepCopyObject().(runtimeclient.Object), nil) + + // then + require.Error(t, err) + }) + + t.Run("when applied for the other cluster type", func(t *testing.T) { + t.Run("single-cluster mode disabled", func(t *testing.T) { + // given + toBeStored, expected := prepareObjects(t, "john", namespace, object) + cache := objectsCache{} + require.NoError(t, cache.ensureObject(newFakeClusterContext(ctx, clusterType), toBeStored, nil)) + + // when + err := cache.ensureObject(newFakeClusterContext(ctx, clusterType.TheOtherType()), toBeStored, nil) + + // then + require.NoError(t, err) + actual := object.DeepCopyObject().(runtimeclient.Object) + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertObject(toBeStored.GetNamespace(), "john", actual, func() { + assert.Equal(t, expected, actual) + }) + actual2 := object.DeepCopyObject().(runtimeclient.Object) + inObjectCache(t, ctx.outDir, clusterType.TheOtherType().String(), cache). + assertObject(toBeStored.GetNamespace(), "john", actual2, func() { + assert.Equal(t, expected, actual2) + }) + inObjectCache(t, ctx.outDir, "base", cache). + assertObjectDoesNotExist(toBeStored.GetNamespace(), "john", object) + }) + + t.Run("single-cluster mode enabled", func(t *testing.T) { + // given + ctx := newSetupContextWithDefaultFiles(t, nil) + ctx.setupFlags.singleCluster = true + + t.Run("update after move to base", func(t *testing.T) { + // given + toBeStored, expected := prepareObjects(t, "john", namespace, object) + cache := objectsCache{} + require.NoError(t, cache.ensureObject(newFakeClusterContext(ctx, clusterType), toBeStored, nil)) + + // when + err := cache.ensureObject(newFakeClusterContext(ctx, clusterType.TheOtherType()), toBeStored, nil) + + // then + require.NoError(t, err) + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertObjectDoesNotExist(toBeStored.GetNamespace(), "john", object) + inObjectCache(t, ctx.outDir, clusterType.TheOtherType().String(), cache). + assertObjectDoesNotExist(toBeStored.GetNamespace(), "john", object) + baseActual := object.DeepCopyObject().(runtimeclient.Object) + inObjectCache(t, ctx.outDir, "base", cache). + assertObject(toBeStored.GetNamespace(), "john", baseActual, func() { + assert.Equal(t, expected, baseActual) + }) + + verifyUpdates(t, newFakeClusterContext(ctx, clusterType), cache, object, toBeStored, expected, "base") + }) + + t.Run("update while moving to base", func(t *testing.T) { + // given + toBeStored, expected := prepareObjects(t, "john", namespace, object) + modifiedSA := expected.DeepCopyObject().(runtimeclient.Object) + modifiedSA.SetLabels(map[string]string{"dummy-key": "dummy-value"}) + cache := objectsCache{} + require.NoError(t, cache.ensureObject(newFakeClusterContext(ctx, clusterType), toBeStored, nil)) + + // when + err := cache.ensureObject(newFakeClusterContext(ctx, clusterType.TheOtherType()), toBeStored, func(object runtimeclient.Object) (bool, error) { + object.SetLabels(map[string]string{"dummy-key": "dummy-value"}) + return true, nil + }) + + // then + require.NoError(t, err) + inObjectCache(t, ctx.outDir, clusterType.String(), cache). + assertObjectDoesNotExist(toBeStored.GetNamespace(), "john", object) + inObjectCache(t, ctx.outDir, clusterType.TheOtherType().String(), cache). + assertObjectDoesNotExist(toBeStored.GetNamespace(), "john", object) + baseActual := object.DeepCopyObject().(runtimeclient.Object) + inObjectCache(t, ctx.outDir, "base", cache). + assertObject(toBeStored.GetNamespace(), "john", baseActual, func() { + assert.Equal(t, modifiedSA, baseActual) + }) + }) + }) + }) + }) + } +} + +func verifyUpdates(t *testing.T, ctx *clusterContext, cache objectsCache, object, toBeStored, expected runtimeclient.Object, expRootDir string) { + t.Run("when manifest should not be updated", func(t *testing.T) { + + for _, noUpdateFunc := range []func(runtimeclient.Object) (bool, error){nil, func(object runtimeclient.Object) (bool, error) { + object.SetLabels(map[string]string{"dummy-key": "dummy-value"}) + return false, nil + }} { + // when + err := cache.ensureObject(ctx, toBeStored, noUpdateFunc) + + // then + require.NoError(t, err) + actual := object.DeepCopyObject().(runtimeclient.Object) + inObjectCache(t, ctx.outDir, expRootDir, cache). + assertObject(toBeStored.GetNamespace(), "john", actual, func() { + assert.Equal(t, expected, actual) + }) + } + + t.Run("when manifest should be updated", func(t *testing.T) { + // given + modifiedSA := expected.DeepCopyObject().(runtimeclient.Object) + modifiedSA.SetLabels(map[string]string{"dummy-key": "dummy-value"}) + + // when + err := cache.ensureObject(ctx, toBeStored, func(object runtimeclient.Object) (bool, error) { + object.SetLabels(map[string]string{"dummy-key": "dummy-value"}) + return true, nil + }) + + // then + require.NoError(t, err) + actual := object.DeepCopyObject().(runtimeclient.Object) + inObjectCache(t, ctx.outDir, expRootDir, cache). + assertObject(toBeStored.GetNamespace(), "john", actual, func() { + assert.Equal(t, modifiedSA, actual) + }) + }) + }) +} + +func TestWriteManifests(t *testing.T) { + // given + ctx := newSetupContextWithDefaultFiles(t, nil) + cache := objectsCache{} + for _, clusterType := range configuration.ClusterTypes { + for _, namespace := range []string{"johnspace", "second-namespace", ""} { + clusterCtx := newFakeClusterContext(ctx, clusterType.TheOtherType()) + user, _ := prepareObjects(t, "john", namespace, &v1.User{}) + require.NoError(t, cache.storeObject(clusterCtx, user)) + sa, _ := prepareObjects(t, "john", namespace, &corev1.ServiceAccount{}) + require.NoError(t, cache.storeObject(clusterCtx, sa)) + } + } + + // when + err := cache.writeManifests(ctx) + + // then + require.NoError(t, err) + for path, expObject := range cache { + obj, err := scheme.Scheme.New(expObject.GetObjectKind().GroupVersionKind()) + require.NoError(t, err) + object := obj.(runtimeclient.Object) + assertObjectAsFile(t, path, expObject.GetNamespace(), expObject.GetName(), object, func() { + assert.Equal(t, expObject, object) + }) + + splitPath := strings.Split(strings.TrimPrefix(path, ctx.outDir), string(filepath.Separator)) + assertKustomizationFiles(t, ctx.outDir, splitPath[1], path) + } +} + +func TestWriteManifest(t *testing.T) { + for _, rootDir := range []string{"host", "member", "base"} { + t.Run("for root dir "+rootDir, func(t *testing.T) { + // given + ctx := newSetupContextWithDefaultFiles(t, nil) + path := filepath.Join(ctx.outDir, rootDir, "test", "resource.yaml") + _, expectedObject := prepareObjects(t, "john", "john-comp", &corev1.ServiceAccount{}) + + // when + err := writeManifest(ctx, path, expectedObject) + + // then + require.NoError(t, err) + sa := &corev1.ServiceAccount{} + assertObjectAsFile(t, path, expectedObject.GetNamespace(), expectedObject.GetName(), sa, func() { + assert.Equal(t, expectedObject, sa) + }) + + splitPath := strings.Split(strings.TrimPrefix(path, ctx.outDir), string(filepath.Separator)) + assertKustomizationFiles(t, ctx.outDir, splitPath[1], path) + }) + } +} diff --git a/pkg/cmd/adm/unregister_member.go b/pkg/cmd/adm/unregister_member.go new file mode 100644 index 0000000..674a0cf --- /dev/null +++ b/pkg/cmd/adm/unregister_member.go @@ -0,0 +1,66 @@ +package adm + +import ( + "context" + "fmt" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/types" +) + +func NewUnregisterMemberCmd() *cobra.Command { + return &cobra.Command{ + Use: "unregister-member ", + Short: "Deletes member from host", + Long: `Deletes the member cluster from the host cluster. It doesn't touch the member cluster itself. Make sure there is no users left in the member cluster before unregistering it.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return UnregisterMemberCluster(ctx, args[0]) + }, + } +} + +func UnregisterMemberCluster(ctx *clicontext.CommandContext, clusterName string) error { + hostClusterConfig, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + hostClusterClient, err := ctx.NewClient(hostClusterConfig.Token, hostClusterConfig.ServerAPI) + if err != nil { + return err + } + + clusterDef, err := configuration.LoadClusterAccessDefinition(ctx, clusterName) + if err != nil { + return err + } + clusterResourceName := fmt.Sprintf("%s-%s", clusterDef.ClusterType, clusterDef.ServerName) + + toolchainCluster := &toolchainv1alpha1.ToolchainCluster{} + if err := hostClusterClient.Get(context.TODO(), types.NamespacedName{Namespace: hostClusterConfig.SandboxNamespace, Name: clusterResourceName}, toolchainCluster); err != nil { + return err + } + if err := ctx.PrintObject(toolchainCluster, "Toolchain Member cluster"); err != nil { + return err + } + confirmation := ctx.AskForConfirmation(ioutils.WithDangerZoneMessagef("unregistering member cluster form host cluster. Make sure there is no users left in the member cluster before unregistering it.", + "Delete Member cluster stated above from the Host cluster?")) + if !confirmation { + return nil + } + + if err := hostClusterClient.Delete(context.TODO(), toolchainCluster); err != nil { + return err + } + ctx.Printlnf("\nThe deletion of the Toolchain member cluster from the Host cluster has been triggered") + + return restartHostOperator(ctx, hostClusterClient, hostClusterConfig) +} diff --git a/pkg/cmd/adm/unregister_member_test.go b/pkg/cmd/adm/unregister_member_test.go new file mode 100644 index 0000000..8e76b81 --- /dev/null +++ b/pkg/cmd/adm/unregister_member_test.go @@ -0,0 +1,123 @@ +package adm + +import ( + "testing" + + "github.com/codeready-toolchain/toolchain-common/pkg/test" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnregisterMemberWhenAnswerIsY(t *testing.T) { + // given + toolchainCluster := NewToolchainCluster(ToolchainClusterName("member-cool-server.com")) + hostDeploymentName := test.NamespacedName("toolchain-host-operator", "host-operator-controller-manager") + deployment := newDeployment(hostDeploymentName, 1) + deployment.Labels = map[string]string{"olm.owner.namespace": "toolchain-host-operator"} + + newClient, newRESTClient, fakeClient := NewFakeClients(t, toolchainCluster, deployment) + numberOfUpdateCalls := 0 + fakeClient.MockUpdate = whenDeploymentThenUpdated(t, fakeClient, hostDeploymentName, 1, &numberOfUpdateCalls) + + SetFileConfig(t, Host(), Member()) + term := NewFakeTerminalWithResponse("y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := UnregisterMemberCluster(ctx, "member1") + + // then + require.NoError(t, err) + AssertToolchainClusterDoesNotExist(t, fakeClient, toolchainCluster) + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.NotContains(t, term.Output(), "THIS COMMAND WILL CAUSE UNREGISTER MEMBER CLUSTER FORM HOST CLUSTER. MAKE SURE THERE IS NO USERS LEFT IN THE MEMBER CLUSTER BEFORE UNREGISTERING IT") + assert.Contains(t, term.Output(), "Delete Member cluster stated above from the Host cluster?") + assert.Contains(t, term.Output(), "The deletion of the Toolchain member cluster from the Host cluster has been triggered") + assert.NotContains(t, term.Output(), "cool-token") + + AssertDeploymentHasReplicas(t, fakeClient, hostDeploymentName, 1) + assert.Equal(t, 2, numberOfUpdateCalls) +} + +func TestUnregisterMemberWhenAnswerIsN(t *testing.T) { + // given + toolchainCluster := NewToolchainCluster(ToolchainClusterName("member-cool-server.com")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, toolchainCluster) + SetFileConfig(t, Host(), Member()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := UnregisterMemberCluster(ctx, "member1") + + // then + require.NoError(t, err) + AssertToolchainClusterSpec(t, fakeClient, toolchainCluster) + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.NotContains(t, term.Output(), "THIS COMMAND WILL CAUSE UNREGISTER MEMBER CLUSTER FORM HOST CLUSTER. MAKE SURE THERE IS NO USERS LEFT IN THE MEMBER CLUSTER BEFORE UNREGISTERING IT") + assert.Contains(t, term.Output(), "Delete Member cluster stated above from the Host cluster?") + assert.NotContains(t, term.Output(), "The deletion of the Toolchain member cluster from the Host cluster has been triggered") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestUnregisterMemberWhenNotFound(t *testing.T) { + // given + toolchainCluster := NewToolchainCluster(ToolchainClusterName("another-cool-server.com")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, toolchainCluster) + SetFileConfig(t, Host(), Member()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := UnregisterMemberCluster(ctx, "member1") + + // then + require.EqualError(t, err, "toolchainclusters.toolchain.dev.openshift.com \"member-cool-server.com\" not found") + AssertToolchainClusterSpec(t, fakeClient, toolchainCluster) + assert.NotContains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.NotContains(t, term.Output(), "THIS COMMAND WILL CAUSE UNREGISTER MEMBER CLUSTER FORM HOST CLUSTER. MAKE SURE THERE IS NO USERS LEFT IN THE MEMBER CLUSTER BEFORE UNREGISTERING IT") + assert.NotContains(t, term.Output(), "Delete Member cluster stated above from the Host cluster?") + assert.NotContains(t, term.Output(), "The deletion of the Toolchain member cluster from the Host cluster has been triggered") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestUnregisterMemberWhenUnknownClusterName(t *testing.T) { + // given + toolchainCluster := NewToolchainCluster(ToolchainClusterName("member-cool-server.com")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, toolchainCluster) + SetFileConfig(t, Host(), Member()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := UnregisterMemberCluster(ctx, "some") + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "the provided cluster-name 'some' is not present in your sandbox.yaml file.") + AssertToolchainClusterSpec(t, fakeClient, toolchainCluster) + assert.NotContains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.NotContains(t, term.Output(), "THIS COMMAND WILL CAUSE UNREGISTER MEMBER CLUSTER FORM HOST CLUSTER. MAKE SURE THERE IS NO USERS LEFT IN THE MEMBER CLUSTER BEFORE UNREGISTERING IT") + assert.NotContains(t, term.Output(), "Delete Member cluster stated above from the Host cluster?") + assert.NotContains(t, term.Output(), "The deletion of the Toolchain member cluster from the Host cluster has been triggered") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestUnregisterMemberLacksPermissions(t *testing.T) { + // given + SetFileConfig(t, Host(NoToken()), Member(NoToken())) + + toolchainCluster := NewToolchainCluster(ToolchainClusterName("member-cool-server.com")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, toolchainCluster) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := UnregisterMemberCluster(ctx, "member1") + + // then + require.EqualError(t, err, "sandbox command failed: the token in your sandbox.yaml file is missing") + AssertToolchainClusterSpec(t, fakeClient, toolchainCluster) +} diff --git a/pkg/cmd/approve.go b/pkg/cmd/approve.go new file mode 100644 index 0000000..91e80f6 --- /dev/null +++ b/pkg/cmd/approve.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "context" + "fmt" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/hash" + "github.com/codeready-toolchain/toolchain-common/pkg/states" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/types" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewApproveCmd() *cobra.Command { + var skipPhone bool + var usersignupName string + var emailAddress string + var targetCluster string + command := &cobra.Command{ + Use: "approve <--email someone@example.com or --name someone>", + Short: "Approve the given UserSignup resource", + Long: `Approve the given UserSignup resource. There is expected +only one parameter which is the name of the UserSignup to be approved`, + Args: cobra.ExactArgs(0), + PreRunE: func(cmd *cobra.Command, args []string) error { + if usersignupName != "" && emailAddress != "" { + return fmt.Errorf("you cannot specify both 'name' and `email` flags") + } + if usersignupName == "" && emailAddress == "" { + return fmt.Errorf("you must specify one of 'name' and `email` flags") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + switch { + case usersignupName != "": + return Approve(ctx, ByName(usersignupName), skipPhone, targetCluster) + default: + return Approve(ctx, ByEmailAddress(emailAddress), skipPhone, targetCluster) + } + }, + } + command.Flags().StringVar(&usersignupName, "name", "", "the name of the UserSignup resource") + command.Flags().StringVar(&emailAddress, "email", "", "the email address of the user") + command.Flags().BoolVarP(&skipPhone, "skip-phone-check", "s", false, "skip the phone hash label check") + command.Flags().StringVar(&targetCluster, "target-cluster", "", "the target cluster where the user should be provisioned") + return command +} + +type LookupUserSignup func(configuration.ClusterConfig, runtimeclient.Client) (*toolchainv1alpha1.UserSignup, error) + +func ByName(name string) LookupUserSignup { + return func(cfg configuration.ClusterConfig, cl runtimeclient.Client) (*toolchainv1alpha1.UserSignup, error) { + userSignup := &toolchainv1alpha1.UserSignup{} + err := cl.Get(context.TODO(), types.NamespacedName{ + Namespace: cfg.SandboxNamespace, + Name: name, + }, userSignup) + return userSignup, err + } +} + +func ByEmailAddress(emailAddress string) LookupUserSignup { + return func(cfg configuration.ClusterConfig, cl runtimeclient.Client) (*toolchainv1alpha1.UserSignup, error) { + usersignups := toolchainv1alpha1.UserSignupList{} + if err := cl.List(context.TODO(), &usersignups, runtimeclient.InNamespace(cfg.SandboxNamespace), runtimeclient.MatchingLabels{ + toolchainv1alpha1.UserSignupUserEmailHashLabelKey: hash.EncodeString(emailAddress), + }); err != nil { + return nil, err + } + + // check that there's only 1 usersignup matching the email address + if l := len(usersignups.Items); l != 1 { + return nil, fmt.Errorf("expected a single match with the email address, but found %d", l) + } + u := usersignups.Items[0] + return &u, nil + } +} + +func Approve(ctx *clicontext.CommandContext, lookupUserSignup LookupUserSignup, skipPhone bool, targetCluster string) error { + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + userSignup, err := lookupUserSignup(cfg, cl) + if err != nil { + return err + } + if state, found := userSignup.Labels[toolchainv1alpha1.StateLabelKey]; found && state == toolchainv1alpha1.UserSignupStateLabelValueApproved { + return fmt.Errorf(`UserSignup "%s" is already approved`, userSignup.Name) + } + // check that the usersignup provided a phone number + _, found := userSignup.Labels[toolchainv1alpha1.UserSignupUserPhoneHashLabelKey] + if !skipPhone && !found { + return fmt.Errorf(`UserSignup "%s" is missing a phone hash label - the user may not have provided a phone number for verification. In most cases, the user should be asked to attempt the phone verification process. For exceptions, skip this check using the --skip-phone-check parameter`, userSignup.Name) + } + + if err := ctx.PrintObject(userSignup, "UserSignup to be approved"); err != nil { + return err + } + if !ctx.AskForConfirmation(ioutils.WithMessagef("approve the UserSignup above?")) { + return nil + } + states.SetVerificationRequired(userSignup, false) + states.SetDeactivated(userSignup, false) + states.SetApprovedManually(userSignup, true) + if targetCluster != "" { + if err = setTargetCluster(ctx, targetCluster, userSignup); err != nil { + return err + } + } + if err := cl.Update(context.TODO(), userSignup); err != nil { + return err + } + ctx.Printlnf("UserSignup has been approved") + return nil +} + +func setTargetCluster(ctx *clicontext.CommandContext, targetCluster string, userSignup *toolchainv1alpha1.UserSignup) error { + memberClusterConfig, err := configuration.LoadClusterConfig(ctx, targetCluster) + if err != nil { + return err + } + // target cluster must have 'member' cluster type + if memberClusterConfig.ClusterType != configuration.Member { + return fmt.Errorf("expected target cluster to have clusterType '%s', actual: '%s'", configuration.Member, memberClusterConfig.ClusterType) + } + // set the specified target cluster + userSignup.Spec.TargetCluster = memberToolchainClusterName(memberClusterConfig) + return nil +} diff --git a/pkg/cmd/approve_test.go b/pkg/cmd/approve_test.go new file mode 100644 index 0000000..f20aaf1 --- /dev/null +++ b/pkg/cmd/approve_test.go @@ -0,0 +1,403 @@ +package cmd_test + +import ( + "context" + "fmt" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/states" + "github.com/kubesaw/ksctl/pkg/cmd" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + "k8s.io/apimachinery/pkg/api/errors" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestApprove(t *testing.T) { + + t.Run("when answer is Y", func(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), false, "") + + // then + require.NoError(t, err) + states.SetApprovedManually(userSignup, true) + states.SetVerificationRequired(userSignup, false) + states.SetDeactivated(userSignup, false) + AssertUserSignupSpec(t, fakeClient, userSignup) + output := term.Output() + assert.Contains(t, output, "Are you sure that you want to approve the UserSignup above?") + assert.Contains(t, output, "UserSignup has been approved") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when answer is N", func(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), false, "") + + // then + require.NoError(t, err) + AssertUserSignupSpec(t, fakeClient, userSignup) + output := term.Output() + assert.Contains(t, output, "Are you sure that you want to approve the UserSignup above?") + assert.NotContains(t, output, "UserSignup has been approved") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("reactivate deactivated user", func(t *testing.T) { + // given + userSignup := NewUserSignup(UserSignupDeactivated(true)) + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), false, "") + + // then + require.NoError(t, err) + states.SetApprovedManually(userSignup, true) + states.SetVerificationRequired(userSignup, false) + states.SetDeactivated(userSignup, false) + AssertUserSignupSpec(t, fakeClient, userSignup) + }) + + t.Run("user is already active - automatically approved", func(t *testing.T) { + // given + userSignup := NewUserSignup(UserSignupAutomaticallyApproved(true)) + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), false, "") + + // then + require.EqualError(t, err, fmt.Sprintf(`UserSignup "%s" is already approved`, userSignup.Name)) + AssertUserSignupSpec(t, fakeClient, userSignup) + }) + + t.Run("user is already active - approved by admin", func(t *testing.T) { + // given + userSignup := NewUserSignup(UserSignupApprovedByAdmin(true)) + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), false, "") + + // then + require.EqualError(t, err, fmt.Sprintf(`UserSignup "%s" is already approved`, userSignup.Name)) + states.SetApprovedManually(userSignup, true) // there's an explicit `spec.state` entry when manually approved + AssertUserSignupSpec(t, fakeClient, userSignup) + }) + + t.Run("when usersignup is already approved", func(t *testing.T) { + // given + userSignup := NewUserSignup(UserSignupAutomaticallyApproved(true)) + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), false, "") + + // then + require.EqualError(t, err, fmt.Sprintf(`UserSignup "%s" is already approved`, userSignup.Name)) + AssertUserSignupSpec(t, fakeClient, userSignup) + output := term.Output() + assert.NotContains(t, output, "Are you sure that you want to approve the UserSignup above?") + assert.NotContains(t, output, "UserSignup has been approved") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when getting usersignup failed", func(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, func(configuration.ClusterConfig, runtimeclient.Client) (*toolchainv1alpha1.UserSignup, error) { + return nil, fmt.Errorf("mock error") + }, false, "") + + // then + require.EqualError(t, err, "mock error") + AssertUserSignupSpec(t, fakeClient, userSignup) + output := term.Output() + assert.NotContains(t, output, "Are you sure that you want to approve the UserSignup above?") + assert.NotContains(t, output, "UserSignup has been approved") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("with phone check variations", func(t *testing.T) { + + t.Run("when usersignup has phone hash", func(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), false, "") + + // then + require.NoError(t, err) + states.SetApprovedManually(userSignup, true) + states.SetVerificationRequired(userSignup, false) + states.SetDeactivated(userSignup, false) + AssertUserSignupSpec(t, fakeClient, userSignup) + output := term.Output() + assert.Contains(t, output, "Are you sure that you want to approve the UserSignup above?") + assert.Contains(t, output, "UserSignup has been approved") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when usersignup doesn't have phone hash but skip phone verification flag is set", func(t *testing.T) { + // given + userSignup := NewUserSignup(UserSignupRemoveLabel(toolchainv1alpha1.UserSignupUserPhoneHashLabelKey)) + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), true, "") + + // then + require.NoError(t, err) + states.SetApprovedManually(userSignup, true) + states.SetVerificationRequired(userSignup, false) + states.SetDeactivated(userSignup, false) + AssertUserSignupSpec(t, fakeClient, userSignup) + output := term.Output() + assert.Contains(t, output, "Are you sure that you want to approve the UserSignup above?") + assert.Contains(t, output, "UserSignup has been approved") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when usersignup doesn't have phone hash", func(t *testing.T) { + // given + userSignup := NewUserSignup(UserSignupRemoveLabel(toolchainv1alpha1.UserSignupUserPhoneHashLabelKey)) + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), false, "") + + // then + require.EqualError(t, err, fmt.Sprintf(`UserSignup "%s" is missing a phone hash label - the user may not have provided a phone number for verification. In most cases, the user should be asked to attempt the phone verification process. For exceptions, skip this check using the --skip-phone-check parameter`, userSignup.Name)) + AssertUserSignupSpec(t, fakeClient, userSignup) + output := term.Output() + assert.NotContains(t, output, "Are you sure that you want to approve the UserSignup above?") + assert.NotContains(t, output, "UserSignup has been approved") + assert.NotContains(t, output, "cool-token") + }) + }) + + t.Run("with targetCluster variations", func(t *testing.T) { + t.Run("when targetCluster is valid", func(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, + Host(), + Member(ClusterName("member1"), ServerName("m1.devcluster.openshift.com")), + Member(ClusterName("member2"), ServerName("m2.devcluster.openshift.com"))) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), false, "member1") + + // then + require.NoError(t, err) + states.SetApprovedManually(userSignup, true) + states.SetVerificationRequired(userSignup, false) + states.SetDeactivated(userSignup, false) + // check the expected target cluster matches with the actual one + userSignup.Spec.TargetCluster = "member-m1.devcluster.openshift.com" + AssertUserSignupSpec(t, fakeClient, userSignup) + output := term.Output() + assert.Contains(t, output, "Are you sure that you want to approve the UserSignup above?") + assert.Contains(t, output, "UserSignup has been approved") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when targetCluster is invalid", func(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, _ := NewFakeClients(t, userSignup) + SetFileConfig(t, + Host(), + Member(ClusterName("member1"), ServerName("m1.devcluster.openshift.com")), + Member(ClusterName("member2"), ServerName("m2.devcluster.openshift.com"))) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Approve(ctx, dummyGet(userSignup), false, "non-existent-member") + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "the provided cluster-name 'non-existent-member' is not present in your sandbox.yaml file") + }) + }) +} + +func dummyGet(userSignup *toolchainv1alpha1.UserSignup) cmd.LookupUserSignup { + return func(configuration.ClusterConfig, runtimeclient.Client) (*toolchainv1alpha1.UserSignup, error) { + return userSignup, nil + } +} + +func TestLookupUserSignupByName(t *testing.T) { + + t.Run("when user is found", func(t *testing.T) { + userSignup := NewUserSignup() + _, _, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + cfg, err := configuration.LoadClusterConfig(term, "host") + require.NoError(t, err) + + // when + result, err := cmd.ByName(userSignup.Name)(cfg, fakeClient) + + // then + require.NoError(t, err) + assert.Equal(t, userSignup.Name, result.Name) // comparing the resource names is enough (no need to deal with kind, group/version, etc.) + }) + + t.Run("when user is unknown", func(t *testing.T) { + _, _, fakeClient := NewFakeClients(t) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + cfg, err := configuration.LoadClusterConfig(term, "host") + require.NoError(t, err) + + // when + _, err = cmd.ByName("unknown")(cfg, fakeClient) + + // then + require.Error(t, err) + assert.True(t, errors.IsNotFound(err)) + }) + + t.Run("when error occurrs", func(t *testing.T) { + userSignup := NewUserSignup() + _, _, fakeClient := NewFakeClients(t, userSignup) + fakeClient.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("mock error") + } + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + cfg, err := configuration.LoadClusterConfig(term, "host") + require.NoError(t, err) + + // when + _, err = cmd.ByName(userSignup.Name)(cfg, fakeClient) + + // then + require.EqualError(t, err, "mock error") + }) +} + +func TestLookupUserSignupByEmailAddress(t *testing.T) { + + t.Run("when user is found", func(t *testing.T) { + userSignup := NewUserSignup() + _, _, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + cfg, err := configuration.LoadClusterConfig(term, "host") + require.NoError(t, err) + + // when + result, err := cmd.ByEmailAddress(userSignup.Spec.IdentityClaims.Email)(cfg, fakeClient) + + // then + require.NoError(t, err) + assert.Equal(t, userSignup.Name, result.Name) // comparing the resource names is enough (no need to deal with kind, group/version, etc.) + }) + + t.Run("when no match found", func(t *testing.T) { + userSignup := NewUserSignup() + _, _, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + cfg, err := configuration.LoadClusterConfig(term, "host") + require.NoError(t, err) + + // when + _, err = cmd.ByEmailAddress("unknown@redhat.com")(cfg, fakeClient) + + // then + require.EqualError(t, err, "expected a single match with the email address, but found 0") + }) + + t.Run("when too many matches found", func(t *testing.T) { + userSignup1 := NewUserSignup() + userSignup2 := NewUserSignup() // same email address as userSignup1 + _, _, fakeClient := NewFakeClients(t, userSignup1, userSignup2) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + cfg, err := configuration.LoadClusterConfig(term, "host") + require.NoError(t, err) + + // when + _, err = cmd.ByEmailAddress(userSignup1.Spec.IdentityClaims.Email)(cfg, fakeClient) + + // then + require.EqualError(t, err, "expected a single match with the email address, but found 2") + }) + + t.Run("when error occurrs", func(t *testing.T) { + userSignup := NewUserSignup() + _, _, fakeClient := NewFakeClients(t, userSignup) + fakeClient.MockList = func(ctx context.Context, list runtimeclient.ObjectList, opts ...runtimeclient.ListOption) error { + return fmt.Errorf("mock error") + } + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + cfg, err := configuration.LoadClusterConfig(term, "host") + require.NoError(t, err) + + // when + _, err = cmd.ByEmailAddress(userSignup.Spec.IdentityClaims.Email)(cfg, fakeClient) + + // then + require.EqualError(t, err, "mock error") + }) +} diff --git a/pkg/cmd/ban.go b/pkg/cmd/ban.go new file mode 100644 index 0000000..f6b0318 --- /dev/null +++ b/pkg/cmd/ban.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "context" + "fmt" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewBanCmd() *cobra.Command { + return &cobra.Command{ + Use: "ban ", + Short: "Ban a user for the given UserSignup resource", + Long: `Ban the given UserSignup resource. There is expected +only one parameter which is the name of the UserSignup to be used for banning`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return Ban(ctx, args...) + }, + } +} + +func Ban(ctx *clicontext.CommandContext, args ...string) error { + return CreateBannedUser(ctx, args[0], func(userSignup *toolchainv1alpha1.UserSignup, bannedUser *toolchainv1alpha1.BannedUser) (bool, error) { + if _, exists := bannedUser.Labels[toolchainv1alpha1.BannedUserPhoneNumberHashLabelKey]; !exists { + ctx.Printlnf("\nINFO: The UserSignup doesn't have the label '%s' set, so the resulting BannedUser resource won't have this label either.\n", + toolchainv1alpha1.BannedUserPhoneNumberHashLabelKey) + } + + if err := ctx.PrintObject(bannedUser, "BannedUser resource to be created"); err != nil { + return false, err + } + + confirmation := ctx.AskForConfirmation(ioutils.WithDangerZoneMessagef( + "deletion of all user's namespaces and all related data.\nIn addition, the user won't be able to login any more.", + "ban the user with the UserSignup by creating BannedUser resource that are both above?")) + return confirmation, nil + }) +} + +func CreateBannedUser(ctx *clicontext.CommandContext, userSignupName string, confirm func(*toolchainv1alpha1.UserSignup, *toolchainv1alpha1.BannedUser) (bool, error)) error { + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + + userSignup, err := client.GetUserSignup(cl, cfg.SandboxNamespace, userSignupName) + if err != nil { + return err + } + + bannedUser, err := newBannedUser(userSignup) + if err != nil { + return err + } + + bannedUsers := &toolchainv1alpha1.BannedUserList{} + if err := cl.List(context.TODO(), bannedUsers, runtimeclient.MatchingLabels(bannedUser.Labels), runtimeclient.InNamespace(cfg.SandboxNamespace)); err != nil { + return err + } + + if err := ctx.PrintObject(userSignup, "UserSignup to be banned"); err != nil { + return err + } + if len(bannedUsers.Items) > 0 { + ctx.Println("The user was already banned - there is a BannedUser resource with the same labels already present") + return ctx.PrintObject(&bannedUsers.Items[0], "BannedUser resource") + } + + if shouldCreate, err := confirm(userSignup, bannedUser); !shouldCreate || err != nil { + return err + } + + if err := cl.Create(context.TODO(), bannedUser); err != nil { + return err + } + + ctx.Printlnf("\nUserSignup has been banned by creating BannedUser resource with name " + bannedUser.Name) + return nil +} + +func newBannedUser(userSignup *toolchainv1alpha1.UserSignup) (*toolchainv1alpha1.BannedUser, error) { + var emailHashLbl, phoneHashLbl string + var exists bool + + if userSignup.Spec.IdentityClaims.Email == "" { + return nil, fmt.Errorf("the UserSignup doesn't have email set") + } + + if emailHashLbl, exists = userSignup.Labels[toolchainv1alpha1.UserSignupUserEmailHashLabelKey]; !exists { + return nil, fmt.Errorf("the UserSignup doesn't have the label '%s' set", toolchainv1alpha1.UserSignupUserEmailHashLabelKey) + } + + bannedUser := &toolchainv1alpha1.BannedUser{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: userSignup.Namespace, + GenerateName: "banneduser-", + Labels: map[string]string{ + toolchainv1alpha1.BannedUserEmailHashLabelKey: emailHashLbl, + }, + }, + Spec: toolchainv1alpha1.BannedUserSpec{ + Email: userSignup.Spec.IdentityClaims.Email, + }, + } + + if phoneHashLbl, exists = userSignup.Labels[toolchainv1alpha1.UserSignupUserPhoneHashLabelKey]; exists { + bannedUser.Labels[toolchainv1alpha1.BannedUserPhoneNumberHashLabelKey] = phoneHashLbl + } + return bannedUser, nil +} diff --git a/pkg/cmd/ban_test.go b/pkg/cmd/ban_test.go new file mode 100644 index 0000000..0da3aba --- /dev/null +++ b/pkg/cmd/ban_test.go @@ -0,0 +1,229 @@ +package cmd_test + +import ( + "context" + "fmt" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestBanCmdWhenAnswerIsY(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Ban(ctx, userSignup.Name) + + // then + require.NoError(t, err) + AssertBannedUser(t, fakeClient, userSignup) + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), "Are you sure that you want to ban the user with the UserSignup by creating BannedUser resource that are both above?") + assert.Contains(t, term.Output(), "UserSignup has been banned") + assert.NotContains(t, term.Output(), "cool-token") + + t.Run("don't ban twice", func(t *testing.T) { + // given + term := NewFakeTerminalWithResponse("y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Ban(ctx, userSignup.Name) + + // then + require.NoError(t, err) + AssertBannedUser(t, fakeClient, userSignup) + assert.NotContains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), "The user was already banned - there is a BannedUser resource with the same labels already present") + }) +} + +func TestBanCmdWhenAnswerIsN(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Ban(ctx, userSignup.Name) + + // then + require.NoError(t, err) + AssertNoBannedUser(t, fakeClient, userSignup) + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), "Are you sure that you want to ban the user with the UserSignup by creating BannedUser resource that are both above?") + assert.NotContains(t, term.Output(), "UserSignup has been banned") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestBanCmdWhenNotFound(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Ban(ctx, "some") + + // then + require.EqualError(t, err, "usersignups.toolchain.dev.openshift.com \"some\" not found") + AssertNoBannedUser(t, fakeClient, userSignup) + assert.NotContains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.NotContains(t, term.Output(), "Are you sure that you want to ban the user with the UserSignup by creating BannedUser resource that are both above?") + assert.NotContains(t, term.Output(), "UserSignup has been banned") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestCreateBannedUser(t *testing.T) { + // given + SetFileConfig(t, Host()) + + t.Run("BannedUser creation is successful", func(t *testing.T) { + //given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.CreateBannedUser(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup, bannedUser *toolchainv1alpha1.BannedUser) (bool, error) { + return true, nil + }) + + // then + require.NoError(t, err) + AssertBannedUser(t, fakeClient, userSignup) + }) + + t.Run("BannedUser should not be created", func(t *testing.T) { + //given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.CreateBannedUser(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup, bannedUser *toolchainv1alpha1.BannedUser) (bool, error) { + return false, nil + }) + + // then + require.NoError(t, err) + AssertNoBannedUser(t, fakeClient, userSignup) + }) + + t.Run("confirmation func returns error", func(t *testing.T) { + //given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.CreateBannedUser(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup, bannedUser *toolchainv1alpha1.BannedUser) (bool, error) { + return false, fmt.Errorf("some error") + }) + + // then + require.EqualError(t, err, "some error") + AssertNoBannedUser(t, fakeClient, userSignup) + }) + + t.Run("get of UserSignup fails", func(t *testing.T) { + //given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + fakeClient.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("some error") + } + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.CreateBannedUser(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup, bannedUser *toolchainv1alpha1.BannedUser) (bool, error) { + return true, nil + }) + + // then + require.EqualError(t, err, "some error") + AssertNoBannedUser(t, fakeClient, userSignup) + }) + + t.Run("creation of BannedUser fails", func(t *testing.T) { + //given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + fakeClient.MockCreate = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.CreateOption) error { + return fmt.Errorf("some error") + } + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.CreateBannedUser(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup, bannedUser *toolchainv1alpha1.BannedUser) (bool, error) { + return true, nil + }) + + // then + require.EqualError(t, err, "some error") + AssertNoBannedUser(t, fakeClient, userSignup) + }) + + t.Run("client creation fails", func(t *testing.T) { + //given + userSignup := NewUserSignup() + fakeClient := test.NewFakeClient(t, userSignup) + term := NewFakeTerminal() + newClient := func(token, apiEndpoint string) (runtimeclient.Client, error) { + return nil, fmt.Errorf("some error") + } + newRESTClient := client.DefaultNewRESTClient + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.CreateBannedUser(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup, bannedUser *toolchainv1alpha1.BannedUser) (bool, error) { + return true, nil + }) + + // then + require.EqualError(t, err, "some error") + AssertNoBannedUser(t, fakeClient, userSignup) + }) +} + +func TestCreateBannedUserLacksPermissions(t *testing.T) { + // given + SetFileConfig(t, Host(NoToken())) + + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.CreateBannedUser(ctx, userSignup.Name, func(signup *toolchainv1alpha1.UserSignup, bannedUser *toolchainv1alpha1.BannedUser) (bool, error) { + return true, nil + }) + + // then + require.EqualError(t, err, "sandbox command failed: the token in your sandbox.yaml file is missing") + AssertUserSignupSpec(t, fakeClient, userSignup) +} diff --git a/pkg/cmd/base_kubectl.go b/pkg/cmd/base_kubectl.go new file mode 100644 index 0000000..c8cde85 --- /dev/null +++ b/pkg/cmd/base_kubectl.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/cmd/flags" + "github.com/kubesaw/ksctl/pkg/configuration" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +type newCmd func(cmdutil.Factory, genericclioptions.IOStreams) *cobra.Command + +// setupKubectlCmd takes care of setting up the flags and PreRunE func on the given Kubectl command +func setupKubectlCmd(newCmd newCmd) *cobra.Command { + kubeConfigFlags := genericclioptions.NewConfigFlags(true).WithDeprecatedPasswordFlag() + factory := cmdutil.NewFactory(cmdutil.NewMatchVersionFlags(kubeConfigFlags)) + ioStreams := genericclioptions.IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + } + cmd := newCmd(factory, ioStreams) + cmd.Example = strings.ReplaceAll(cmd.Example, "kubectl ", "sandbox-cli ") + + // hide unused/redefined flags + kubeConfigFlags.ClusterName = nil // `cluster` flag is redefined for our own purpose + kubeConfigFlags.AuthInfoName = nil // unused here, so we can hide it + kubeConfigFlags.Context = nil // unused here, so we can hide it + kubeConfigFlags.AddFlags(cmd.Flags()) // add default flags to the command (so we have `-n`, etc.) + + cmd.Flags().StringP("target-cluster", "t", "", "Target cluster") + // will be used to load the config (API Server URL and token) + flags.MustMarkRequired(cmd, "target-cluster") + // flags with values hard-coded by `PreRun` are hidden + flags.MustMarkHidden(cmd, "server") + flags.MustMarkHidden(cmd, "token") + flags.MustMarkHidden(cmd, "kubeconfig") + + // set the "hard-coded" value of some specific flags before running the command, + // by loading the config associated with the `--cluster` flag + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + clusterName := cmd.Flag("target-cluster").Value.String() + if clusterName == "" { // flag is required, but we need to manually verify its presence in the PreRun + return fmt.Errorf("you must specify the target cluster") + } + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + cfg, err := configuration.LoadClusterConfig(term, clusterName) + if err != nil { + return err + } + if !cmd.Flag("namespace").Changed { // default to sandbox namespace + kubeConfigFlags.Namespace = &cfg.SandboxNamespace + } + kubeConfigFlags.APIServer = &cfg.ServerAPI + kubeConfigFlags.BearerToken = &cfg.Token + kubeconfig, err := client.EnsureSandboxCliConfigFile() + if err != nil { + return err + } + kubeConfigFlags.KubeConfig = &kubeconfig + return nil + } + return cmd +} diff --git a/pkg/cmd/create_social_event.go b/pkg/cmd/create_social_event.go new file mode 100644 index 0000000..fc79aff --- /dev/null +++ b/pkg/cmd/create_social_event.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "context" + "fmt" + "time" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + commonsocialevent "github.com/codeready-toolchain/toolchain-common/pkg/socialevent" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/cmd/flags" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + errs "github.com/pkg/errors" + "github.com/spf13/cobra" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func NewCreateSocialEventCmd() *cobra.Command { + var startDate string // format" YYYY-MM-DD + var endDate string // format" YYYY-MM-DD + var maxAttendees int // must be greater than 0 + var description string // optional + var userTier string // optional, default to `base` + var spaceTier string // optional, default to `deactivate30` + var preferSameCluster bool // optional, default to `false` + + command := &cobra.Command{ + Use: "create-event --description= --start-date= --end-date= --max-attendees=", + Short: "Create an event with a code to signup", + Long: `Create an event (workshop, lab, etc.) to which attendees can signup to with a code.`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return CreateSocialEvent(ctx, startDate, endDate, description, userTier, spaceTier, maxAttendees, preferSameCluster) + }, + } + command.Flags().StringVar(&startDate, "start-date", "", "start date of the event/when the activation code becomes valid (YYYY-MM-DD)") + flags.MustMarkRequired(command, "start-date") + command.Flags().StringVar(&endDate, "end-date", "", "end date of the event/when the activation code becomes invalid (YYYY-MM-DD)") + flags.MustMarkRequired(command, "end-date") + command.Flags().IntVar(&maxAttendees, "max-attendees", 0, "maximum number of expected attendees for the event") + flags.MustMarkRequired(command, "max-attendees") + command.Flags().StringVar(&description, "description", "", "event description") + command.Flags().StringVar(&userTier, "user-tier", "deactivate30", "tier to provision users") + command.Flags().StringVar(&spaceTier, "space-tier", "base", "tier to provision spaces") + command.Flags().BoolVar(&preferSameCluster, "prefer-same-cluster", false, "if true, a best effort is made to provision all attendees on the same cluster") + return command +} + +func CreateSocialEvent(ctx *clicontext.CommandContext, startDate, endDate, description, userTier, spaceTier string, maxAttendees int, preferSameCluster bool) error { + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + + // generate a unique ActivationCode if it was not specified in the CLI + code := commonsocialevent.NewName() + // convert the start-time and end-time + start, err := time.ParseInLocation("2006-01-02 15:04:05", startDate+" 00:00:00", time.Local) //nolint:gosmopolitan + if err != nil { + return errs.Wrapf(err, "start date is invalid: '%s' (expected YYYY-MM-DD)", startDate) + } + end, err := time.ParseInLocation("2006-01-02 15:04:05", endDate+" 23:59:59", time.Local) //nolint:gosmopolitan + if err != nil { + return errs.Wrapf(err, "end date is invalid: '%s' (expected YYYY-MM-DD)", endDate) + } + if end.Before(start) { + return errs.New("end date is not after start date") + } + // check that the user and space tiers exist + if err := cl.Get(context.TODO(), types.NamespacedName{ + Namespace: cfg.SandboxNamespace, + Name: userTier, + }, &toolchainv1alpha1.UserTier{}); err != nil { + if apierrors.IsNotFound(err) { + return fmt.Errorf("UserTier '%s' does not exist", userTier) + } + } + if err := cl.Get(context.TODO(), types.NamespacedName{ + Namespace: cfg.SandboxNamespace, + Name: spaceTier, + }, &toolchainv1alpha1.NSTemplateTier{}); err != nil { + if apierrors.IsNotFound(err) { + return fmt.Errorf("NSTemplateTier '%s' does not exist", spaceTier) + } + } + + se := &toolchainv1alpha1.SocialEvent{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: cfg.SandboxNamespace, + Name: code, + }, + Spec: toolchainv1alpha1.SocialEventSpec{ + StartTime: metav1.NewTime(start), + EndTime: metav1.NewTime(end), + MaxAttendees: maxAttendees, + UserTier: userTier, + SpaceTier: spaceTier, + Description: description, + PreferSameCluster: preferSameCluster, + }, + } + + if err := cl.Create(context.TODO(), se); err != nil { + return err + } + ctx.Printlnf("Social Event successfully created. Activation code is '%s'", se.Name) + return nil +} diff --git a/pkg/cmd/create_social_event_test.go b/pkg/cmd/create_social_event_test.go new file mode 100644 index 0000000..9e65c81 --- /dev/null +++ b/pkg/cmd/create_social_event_test.go @@ -0,0 +1,163 @@ +package cmd_test + +import ( + "context" + "fmt" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestCreateSocialEvent(t *testing.T) { + + spaceTier := newNSTemplateTier("base") + userTier := newUserTier("deactivate30") + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("y") + + t.Run("success", func(t *testing.T) { + + t.Run("1-day event without description", func(t *testing.T) { + // given + newClient, newRESTClient, fakeClient := NewFakeClients(t, userTier, spaceTier) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + startDate := "2022-06-21" // summer 🏝 + endDate := "2022-06-21" // ends same day + description := "" + maxAttendees := 10 + // when + err := cmd.CreateSocialEvent(ctx, startDate, endDate, description, userTier.Name, spaceTier.Name, maxAttendees, false) + + // then + require.NoError(t, err) + assert.Contains(t, term.Output(), "Social Event successfully created") + // verify that the SocialEvent was created + ses := &toolchainv1alpha1.SocialEventList{} + err = fakeClient.List(context.TODO(), ses, runtimeclient.InNamespace(test.HostOperatorNs)) + require.NoError(t, err) + require.Len(t, ses.Items, 1) + event := ses.Items[0] + assert.Equal(t, startDate, event.Spec.StartTime.Format("2006-01-02")) + assert.Equal(t, endDate, event.Spec.EndTime.Format("2006-01-02")) + assert.Equal(t, userTier.Name, event.Spec.UserTier) + assert.Equal(t, spaceTier.Name, event.Spec.SpaceTier) + assert.Equal(t, maxAttendees, event.Spec.MaxAttendees) + assert.Empty(t, event.Spec.Description) + }) + + t.Run("2-day event", func(t *testing.T) { + // given + newClient, newRESTClient, fakeClient := NewFakeClients(t, userTier, spaceTier) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + startDate := "2022-06-21" // summer 🏝 + endDate := "2022-06-22" + description := "summer workshop" + maxAttendees := 10 + // when + err := cmd.CreateSocialEvent(ctx, startDate, endDate, description, userTier.Name, spaceTier.Name, maxAttendees, false) + + // then + require.NoError(t, err) + // verify that the SocialEvent was created + ses := &toolchainv1alpha1.SocialEventList{} + err = fakeClient.List(context.TODO(), ses, runtimeclient.InNamespace(test.HostOperatorNs)) + require.NoError(t, err) + require.Len(t, ses.Items, 1) + event := ses.Items[0] + assert.Equal(t, description, event.Spec.Description) + // no need to re-verify other fields, test above already took care of them + }) + }) + + t.Run("failures", func(t *testing.T) { + + t.Run("invalid start date", func(t *testing.T) { + // given + newClient, newRESTClient, _ := NewFakeClients(t, userTier, spaceTier) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + startDate := "2022-06-xx" // invalid! + endDate := "2022-06-22" + description := "summer workshop" + maxAttendees := 10 + // when + err := cmd.CreateSocialEvent(ctx, startDate, endDate, description, userTier.Name, spaceTier.Name, maxAttendees, false) + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "start date is invalid: '2022-06-xx' (expected YYYY-MM-DD)") + }) + + t.Run("invalid end date", func(t *testing.T) { + // given + newClient, newRESTClient, _ := NewFakeClients(t, userTier, spaceTier) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + startDate := "2022-06-21" + endDate := "2022-06-32" // invalid value! + description := "summer workshop" + maxAttendees := 10 + // when + err := cmd.CreateSocialEvent(ctx, startDate, endDate, description, userTier.Name, spaceTier.Name, maxAttendees, false) + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "end date is invalid: '2022-06-32' (expected YYYY-MM-DD)") + }) + + t.Run("end date before start date", func(t *testing.T) { + // given + newClient, newRESTClient, _ := NewFakeClients(t, userTier, spaceTier) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + startDate := "2022-06-21" + endDate := "2022-06-11" // before start date! + description := "summer workshop" + maxAttendees := 10 + // when + err := cmd.CreateSocialEvent(ctx, startDate, endDate, description, userTier.Name, spaceTier.Name, maxAttendees, false) + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "end date is not after start date") + }) + + t.Run("usertier does not exist", func(t *testing.T) { + // given + newClient, newRESTClient, _ := NewFakeClients(t, spaceTier) // no user tier + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + startDate := "2022-06-21" + endDate := "2022-06-22" + description := "summer workshop" + maxAttendees := 10 + // when + err := cmd.CreateSocialEvent(ctx, startDate, endDate, description, userTier.Name, spaceTier.Name, maxAttendees, false) + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("UserTier '%s' does not exist", userTier.Name)) + }) + + t.Run("nstemplatetier does not exist", func(t *testing.T) { + // given + newClient, newRESTClient, _ := NewFakeClients(t, userTier) // no space tier + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + startDate := "2022-06-21" + endDate := "2022-06-22" + description := "summer workshop" + maxAttendees := 10 + // when + err := cmd.CreateSocialEvent(ctx, startDate, endDate, description, userTier.Name, spaceTier.Name, maxAttendees, false) + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("NSTemplateTier '%s' does not exist", spaceTier.Name)) + }) + + }) +} diff --git a/pkg/cmd/deactivate.go b/pkg/cmd/deactivate.go new file mode 100644 index 0000000..aef1e1b --- /dev/null +++ b/pkg/cmd/deactivate.go @@ -0,0 +1,41 @@ +package cmd + +import ( + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/states" + "github.com/kubesaw/ksctl/pkg/client" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" +) + +func NewDeactivateCmd() *cobra.Command { + return &cobra.Command{ + Use: "deactivate ", + Short: "Deactivate the given UserSignup resource", + Long: `Deactivate the given UserSignup resource. There is expected +only one parameter which is the name of the UserSignup to be deactivated`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return Deactivate(ctx, args...) + }, + } +} + +func Deactivate(ctx *clicontext.CommandContext, args ...string) error { + return client.PatchUserSignup(ctx, args[0], func(userSignup *toolchainv1alpha1.UserSignup) (bool, error) { + if err := ctx.PrintObject(userSignup, "UserSignup to be deactivated"); err != nil { + return false, err + } + confirmation := ctx.AskForConfirmation(ioutils.WithDangerZoneMessagef( + "deletion of all user's namespaces and all related data", "deactivate the UserSignup above?")) + if confirmation { + states.SetDeactivated(userSignup, true) + return true, nil + } + return false, nil + }, "UserSignup has been deactivated") +} diff --git a/pkg/cmd/deactivate_test.go b/pkg/cmd/deactivate_test.go new file mode 100644 index 0000000..f8f2efc --- /dev/null +++ b/pkg/cmd/deactivate_test.go @@ -0,0 +1,75 @@ +package cmd_test + +import ( + "testing" + + "github.com/codeready-toolchain/toolchain-common/pkg/states" + + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeactivateCmdWhenAnswerIsY(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Deactivate(ctx, userSignup.Name) + + // then + require.NoError(t, err) + states.SetDeactivated(userSignup, true) + AssertUserSignupSpec(t, fakeClient, userSignup) + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), "Are you sure that you want to deactivate the UserSignup above?") + assert.Contains(t, term.Output(), "UserSignup has been deactivated") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestDeactivateCmdWhenAnswerIsN(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Deactivate(ctx, userSignup.Name) + + // then + require.NoError(t, err) + AssertUserSignupSpec(t, fakeClient, userSignup) + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), "Are you sure that you want to deactivate the UserSignup above?") + assert.NotContains(t, term.Output(), "UserSignup has been deactivated") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestDeactivateCmdWhenNotFound(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Deactivate(ctx, "some") + + // then + require.EqualError(t, err, "usersignups.toolchain.dev.openshift.com \"some\" not found") + AssertUserSignupSpec(t, fakeClient, userSignup) + assert.NotContains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.NotContains(t, term.Output(), "Are you sure that you want to deactivate the UserSignup above?") + assert.NotContains(t, term.Output(), "UserSignup has been deactivated") + assert.NotContains(t, term.Output(), "cool-token") +} diff --git a/pkg/cmd/delete.go b/pkg/cmd/delete.go new file mode 100644 index 0000000..cbdbba5 --- /dev/null +++ b/pkg/cmd/delete.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "context" + + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewGdprDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "gdpr-delete ", + Short: "Delete the given UserSignup resource", + Long: `Delete the given UserSignup resource. There is expected +only one parameter which is the name of the UserSignup to be deleted`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return Delete(ctx, args...) + }, + } +} + +func Delete(ctx *clicontext.CommandContext, args ...string) error { + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + userSignup, err := client.GetUserSignup(cl, cfg.SandboxNamespace, args[0]) + if err != nil { + return err + } + if err := ctx.PrintObject(userSignup, "UserSignup to be deleted"); err != nil { + return err + } + confirmation := ctx.AskForConfirmation(ioutils.WithDangerZoneMessagef( + "deletion of all user's namespaces and all related data.\n"+ + "This command should be executed based on GDPR request.", "delete the UserSignup above?")) + if !confirmation { + return nil + } + propagationPolicy := metav1.DeletePropagationForeground + opts := runtimeclient.DeleteOption(&runtimeclient.DeleteOptions{ + PropagationPolicy: &propagationPolicy, + }) + if err := cl.Delete(context.TODO(), userSignup, opts); err != nil { + return err + } + ctx.Printlnf("\nThe deletion of the UserSignup has been triggered") + return nil +} diff --git a/pkg/cmd/delete_test.go b/pkg/cmd/delete_test.go new file mode 100644 index 0000000..bb8d980 --- /dev/null +++ b/pkg/cmd/delete_test.go @@ -0,0 +1,122 @@ +package cmd_test + +import ( + "context" + "testing" + + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestDeleteCmdWhenAnswerIsY(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Delete(ctx, userSignup.Name) + + // then + require.NoError(t, err) + AssertUserSignupDoesNotExist(t, fakeClient, userSignup) + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), "THIS COMMAND SHOULD BE EXECUTED BASED ON GDPR REQUEST.") + assert.Contains(t, term.Output(), "Are you sure that you want to delete the UserSignup above?") + assert.Contains(t, term.Output(), "The deletion of the UserSignup has been triggered") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestDeleteCmdWhenAnswerIsN(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Delete(ctx, userSignup.Name) + + // then + require.NoError(t, err) + AssertUserSignupSpec(t, fakeClient, userSignup) + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), "THIS COMMAND SHOULD BE EXECUTED BASED ON GDPR REQUEST.") + assert.Contains(t, term.Output(), "Are you sure that you want to delete the UserSignup above?") + assert.NotContains(t, term.Output(), "The deletion of the UserSignup has been triggered") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestDeleteCmdWhenNotFound(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Delete(ctx, "some") + + // then + require.EqualError(t, err, "usersignups.toolchain.dev.openshift.com \"some\" not found") + AssertUserSignupSpec(t, fakeClient, userSignup) + assert.NotContains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.NotContains(t, term.Output(), "Are you sure that you want to delete the UserSignup above?") + assert.NotContains(t, term.Output(), "The deletion of the UserSignup has been triggered") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestDeleteLacksPermissions(t *testing.T) { + // given + SetFileConfig(t, Host(NoToken())) + + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Delete(ctx, userSignup.Name) + + // then + require.EqualError(t, err, "sandbox command failed: the token in your sandbox.yaml file is missing") + AssertUserSignupSpec(t, fakeClient, userSignup) +} + +func TestDeleteHasPropagationPolicy(t *testing.T) { + // given + userSignup := NewUserSignup() + newClient, newRESTClient, fakeClient := NewFakeClients(t, userSignup) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("y") + + deleted := false + fakeClient.MockDelete = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.DeleteOption) error { + deleted = true + require.Len(t, opts, 1) + deleteOptions, ok := opts[0].(*runtimeclient.DeleteOptions) + require.True(t, ok) + require.NotNil(t, deleteOptions) + require.NotNil(t, deleteOptions.PropagationPolicy) + assert.Equal(t, metav1.DeletePropagationForeground, *deleteOptions.PropagationPolicy) + return nil + } + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Delete(ctx, userSignup.Name) + + // then + require.NoError(t, err) + require.True(t, deleted) +} diff --git a/pkg/cmd/describe.go b/pkg/cmd/describe.go new file mode 100644 index 0000000..0c99f3e --- /dev/null +++ b/pkg/cmd/describe.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + kubectldesc "k8s.io/kubectl/pkg/cmd/describe" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +func NewDescribeCmd() *cobra.Command { + return setupKubectlCmd(func(factory cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + return kubectldesc.NewCmdDescribe("sandbox-cli", factory, ioStreams) + }) +} diff --git a/pkg/cmd/describe_test.go b/pkg/cmd/describe_test.go new file mode 100644 index 0000000..481db38 --- /dev/null +++ b/pkg/cmd/describe_test.go @@ -0,0 +1,172 @@ +package cmd_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kubesaw/ksctl/pkg/cmd" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDescribe(t *testing.T) { + + // given + server := NewDescribeServer(t) + t.Logf("server URL: %s", server.URL) + defer server.Close() + SetFileConfig(t, Host(ServerAPI(server.URL))) + + t.Run("describe pod with long-hand target cluster and namespace flags", func(t *testing.T) { + // given + describeCmd := cmd.NewDescribeCmd() + describeCmd.SetArgs([]string{ + "--target-cluster=host", + "--namespace=toolchain-host-operator", + "--insecure-skip-tls-verify=true", + "pod/pasta", + }) + + // when + _, err := describeCmd.ExecuteC() + + // then + require.NoError(t, err) + }) + + t.Run("describe pod with short-hand target cluster and namespace flags", func(t *testing.T) { + // given + describeCmd := cmd.NewDescribeCmd() + describeCmd.SetArgs([]string{ + "-t=host", + "-n=toolchain-host-operator", + "--insecure-skip-tls-verify=true", + "pod/pasta", + }) + + // when + _, err := describeCmd.ExecuteC() + + // then + require.NoError(t, err) + }) + + t.Run("describe pod with default namespace", func(t *testing.T) { + // given + describeCmd := cmd.NewDescribeCmd() + describeCmd.SetArgs([]string{ + "--target-cluster=host", + // "--namespace=...", // will default to `toolchain-host-operator` + "--insecure-skip-tls-verify=true", + "pod/pasta", + }) + + // when + _, err := describeCmd.ExecuteC() + + // then + require.NoError(t, err) + }) + + t.Run("missing 'cluster' flag", func(t *testing.T) { + // given + describeCmd := cmd.NewDescribeCmd() + describeCmd.SetArgs([]string{ + "--namespace=toolchain-host-operator", + "--insecure-skip-tls-verify=true", + "pods", + }) + + // when + _, err := describeCmd.ExecuteC() + + // then + require.Error(t, err) + require.Error(t, err, "you must specify the target cluster") + }) +} + +// NewServer returns a new HTTP Server which supports: +// - calls to `/api` +// - calls to `/apis` +// - calls on some predefined resources +// - 404 responses otherwise +// see https://github.com/kubernetes/client-go/blob/master/discovery/discovery_client_test.go +func NewDescribeServer(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var response interface{} + switch req.Method { + case "GET": + switch req.URL.Path { + case "/api/v1": + response = &metav1.APIResourceList{ + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "pods", + SingularName: "pod", + ShortNames: []string{"po"}, + Namespaced: true, + Kind: "Pod", + }, + }, + } + case "/api": + response = &metav1.APIVersions{ + Versions: []string{ + "v1", + }, + } + case "/apis": + response = &metav1.APIGroupList{ + Groups: []metav1.APIGroup{}, + } + + case "/api/v1/namespaces/toolchain-host-operator/pods/pasta": + response = &corev1.Pod{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "toolchain-host-operator", + Name: "pasta", + }, + Spec: corev1.PodSpec{}, + Status: corev1.PodStatus{ + Phase: "Running", + }, + } + case "/api/v1/namespaces/toolchain-host-operator/events": + response = &corev1.EventList{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "EventList", + }, + Items: []corev1.Event{}, + } + default: + t.Errorf("not found: %s %s\n", req.Method, req.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + default: + t.Errorf("unexpected request: %s %s\n", req.Method, req.URL) + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(response) + if err != nil { + t.Errorf("unexpected encoding error: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(output) // nolint: errcheck + })) +} diff --git a/pkg/cmd/disable_user.go b/pkg/cmd/disable_user.go new file mode 100644 index 0000000..f0fce34 --- /dev/null +++ b/pkg/cmd/disable_user.go @@ -0,0 +1,39 @@ +package cmd + +import ( + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/kubesaw/ksctl/pkg/client" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + "github.com/spf13/cobra" +) + +func NewDisableUserCmd() *cobra.Command { + return &cobra.Command{ + Use: "disable-user ", + Short: "Disable the given MasterUserRecord resource", + Long: `Disable the given MasterUserRecord resource. Expects +only one parameter which is the name of the MasterUserRecord to be disabled`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return DisableUser(ctx, args...) + }, + } +} + +func DisableUser(ctx *clicontext.CommandContext, args ...string) error { + return client.PatchMasterUserRecord(ctx, args[0], func(masterUserRecord *toolchainv1alpha1.MasterUserRecord) (bool, error) { + if err := ctx.PrintObject(masterUserRecord, "MasterUserRecord to be disabled"); err != nil { + return false, err + } + confirmation := ctx.AskForConfirmation(ioutils.WithDangerZoneMessagef( + "Disabling the MasterUserRecord will delete User/Identity objects so the user can’t login.", "disable the MasterUserRecord above?")) + if confirmation { + masterUserRecord.Spec.Disabled = true + return true, nil + } + return false, nil + }, "MasterUserRecord has been disabled") +} diff --git a/pkg/cmd/disable_user_test.go b/pkg/cmd/disable_user_test.go new file mode 100644 index 0000000..74bf213 --- /dev/null +++ b/pkg/cmd/disable_user_test.go @@ -0,0 +1,77 @@ +package cmd_test + +import ( + "testing" + + "github.com/codeready-toolchain/toolchain-common/pkg/test/masteruserrecord" + + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDisableUserCmdWhenAnswerIsY(t *testing.T) { + // given + // this mur will be disabled + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, mur1) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.DisableUser(ctx, mur1.Name) + + // then + require.NoError(t, err) + // check if mur was disabled + mur1.Spec.Disabled = true + assertMasterUserRecordSpec(t, fakeClient, mur1) + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), "Are you sure that you want to disable the MasterUserRecord above?") + assert.Contains(t, term.Output(), "MasterUserRecord has been disabled") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestDisableUserCmdWhenAnswerIsN(t *testing.T) { + // given + mur := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, mur) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.DisableUser(ctx, mur.Name) + + // then + require.NoError(t, err) + assertMasterUserRecordSpec(t, fakeClient, mur) + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), "Are you sure that you want to disable the MasterUserRecord above?") + assert.NotContains(t, term.Output(), "MasterUserRecord has been disabled") + assert.NotContains(t, term.Output(), "cool-token") +} + +func TestDisableUserCmdWhenNotFound(t *testing.T) { + // given + mur := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, mur) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.DisableUser(ctx, "some") + + // then + require.EqualError(t, err, "masteruserrecords.toolchain.dev.openshift.com \"some\" not found") + assertMasterUserRecordSpec(t, fakeClient, mur) + assert.NotContains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.NotContains(t, term.Output(), "Are you sure that you want to disable the MasterUserRecord above?") + assert.NotContains(t, term.Output(), "MasterUserRecord has been disabled") + assert.NotContains(t, term.Output(), "cool-token") +} diff --git a/pkg/cmd/flags/flags.go b/pkg/cmd/flags/flags.go new file mode 100644 index 0000000..822bcf9 --- /dev/null +++ b/pkg/cmd/flags/flags.go @@ -0,0 +1,17 @@ +package flags + +import ( + "github.com/spf13/cobra" +) + +func MustMarkHidden(cmd *cobra.Command, name string) { + if err := cmd.Flags().MarkHidden(name); err != nil { + panic(err) + } +} + +func MustMarkRequired(cmd *cobra.Command, name string) { + if err := cmd.MarkFlagRequired(name); err != nil { + panic(err) + } +} diff --git a/pkg/cmd/get.go b/pkg/cmd/get.go new file mode 100644 index 0000000..f3c8069 --- /dev/null +++ b/pkg/cmd/get.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + kubectlget "k8s.io/kubectl/pkg/cmd/get" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +func NewGetCmd() *cobra.Command { + return setupKubectlCmd(func(factory cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + return kubectlget.NewCmdGet("sandbox-cli", factory, ioStreams) + }) +} diff --git a/pkg/cmd/get_test.go b/pkg/cmd/get_test.go new file mode 100644 index 0000000..9da6d3d --- /dev/null +++ b/pkg/cmd/get_test.go @@ -0,0 +1,170 @@ +package cmd_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kubesaw/ksctl/pkg/cmd" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestGet(t *testing.T) { + + // given + server := NewGetServer(t) + t.Logf("server URL: %s", server.URL) + defer server.Close() + SetFileConfig(t, Host(ServerAPI(server.URL))) + + t.Run("get pods with long-hand target cluster and namespace flags", func(t *testing.T) { + // given + getCmd := cmd.NewGetCmd() + getCmd.SetArgs([]string{ + "--target-cluster=host", + "--namespace=toolchain-host-operator", + "--insecure-skip-tls-verify=true", + "pods", + }) + + // when + _, err := getCmd.ExecuteC() + + // then + require.NoError(t, err) + }) + + t.Run("get pods with short-hand target cluster and namespace flags", func(t *testing.T) { + // given + getCmd := cmd.NewGetCmd() + getCmd.SetArgs([]string{ + "-t=host", + "-n=toolchain-host-operator", + "--insecure-skip-tls-verify=true", + "pods", + }) + + // when + _, err := getCmd.ExecuteC() + + // then + require.NoError(t, err) + }) + + t.Run("get pods with default namespace", func(t *testing.T) { + // given + getCmd := cmd.NewGetCmd() + getCmd.SetArgs([]string{ + "--target-cluster=host", + // "--namespace=...", // will default to `toolchain-host-operator` + "--insecure-skip-tls-verify=true", + "pods", + }) + + // when + _, err := getCmd.ExecuteC() + + // then + require.NoError(t, err) + }) + + t.Run("missing 'cluster' flag", func(t *testing.T) { + // given + getCmd := cmd.NewGetCmd() + getCmd.SetArgs([]string{ + "--namespace=toolchain-host-operator", + "--insecure-skip-tls-verify=true", + "pods", + }) + + // when + _, err := getCmd.ExecuteC() + + // then + require.Error(t, err) + require.Error(t, err, "you must specify the target cluster") + }) +} + +// NewServer returns a new HTTP Server which supports: +// - calls to `/api` +// - calls to `/apis` +// - calls on some predefined resources +// - 404 responses otherwise +// see https://github.com/kubernetes/client-go/blob/master/discovery/discovery_client_test.go +func NewGetServer(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var response interface{} + switch req.Method { + case "GET": + switch req.URL.Path { + case "/api/v1": + response = &metav1.APIResourceList{ + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "pods", + SingularName: "pod", + ShortNames: []string{"po"}, + Namespaced: true, + Kind: "Pod", + }, + }, + } + case "/api": + response = &metav1.APIVersions{ + Versions: []string{ + "v1", + }, + } + case "/apis": + response = &metav1.APIGroupList{ + Groups: []metav1.APIGroup{}, + } + + case "/api/v1/namespaces/toolchain-host-operator/pods": + response = corev1.PodList{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + }, + ListMeta: metav1.ListMeta{}, + Items: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "toolchain-host-operator", + Name: "cheesecake", + }, + Spec: corev1.PodSpec{}, + Status: corev1.PodStatus{ + Phase: "Running", + }, + }, + }, + } + + default: + t.Errorf("not found: %s %s\n", req.Method, req.URL) + w.WriteHeader(http.StatusNotFound) + return + } + default: + t.Errorf("unexpected request: %s %s\n", req.Method, req.URL) + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(response) + if err != nil { + t.Errorf("unexpected encoding error: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(output) // nolint: errcheck + })) +} diff --git a/pkg/cmd/logs.go b/pkg/cmd/logs.go new file mode 100644 index 0000000..b884e0b --- /dev/null +++ b/pkg/cmd/logs.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + kubectllogs "k8s.io/kubectl/pkg/cmd/logs" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +func NewLogsCmd() *cobra.Command { + return setupKubectlCmd(func(factory cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + return kubectllogs.NewCmdLogs(factory, ioStreams) + }) +} diff --git a/pkg/cmd/logs_test.go b/pkg/cmd/logs_test.go new file mode 100644 index 0000000..560ad2d --- /dev/null +++ b/pkg/cmd/logs_test.go @@ -0,0 +1,174 @@ +package cmd_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kubesaw/ksctl/pkg/cmd" + "github.com/kubesaw/ksctl/pkg/configuration" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestLogs(t *testing.T) { + + // given + server := NewLogsServer(t) + t.Logf("server URL: %s", server.URL) + defer server.Close() + SetFileConfig(t, Host(ServerAPI(server.URL))) + configuration.Verbose = true + + t.Run("logs with with long-hand target cluster and namespace flags", func(t *testing.T) { + // given + logsCmd := cmd.NewLogsCmd() + logsCmd.SetArgs([]string{ + "--target-cluster=host", + "--namespace=toolchain-host-operator", + "--insecure-skip-tls-verify=true", + "cheesecake", + }) + + // when + _, err := logsCmd.ExecuteC() + + // then + require.NoError(t, err) + }) + + t.Run("logs with with short-hand target cluster and namespace flags", func(t *testing.T) { + // given + logsCmd := cmd.NewLogsCmd() + logsCmd.SetArgs([]string{ + "-t=host", + "-n=toolchain-host-operator", + "--insecure-skip-tls-verify=true", + "cheesecake", + }) + + // when + _, err := logsCmd.ExecuteC() + + // then + require.NoError(t, err) + }) + + t.Run("logs with default namespace", func(t *testing.T) { + // given + logsCmd := cmd.NewLogsCmd() + logsCmd.SetArgs([]string{ + "--target-cluster=host", + // "--namespace=...", // will default to `toolchain-host-operator` + "--insecure-skip-tls-verify=true", + "cheesecake", + }) + + // when + _, err := logsCmd.ExecuteC() + + // then + require.NoError(t, err) + }) + + t.Run("missing '--cluster' flag", func(t *testing.T) { + // given + logsCmd := cmd.NewLogsCmd() + logsCmd.SetArgs([]string{ + "--namespace=toolchain-host-operator", + "--insecure-skip-tls-verify=true", + "cheesecake", + }) + + // when + _, err := logsCmd.ExecuteC() + + // then + require.Error(t, err) + require.Error(t, err, "you must specify the target cluster") + }) +} + +// NewLogsServer returns a new HTTP Server which supports: +// - calls to `/api` +// - calls to `/apis` +// - calls on some predefined resources +// - 404 responses otherwise +// see https://github.com/kubernetes/client-go/blob/master/discovery/discovery_client_test.go +func NewLogsServer(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var response interface{} + switch req.Method { + case "GET": + switch req.URL.Path { + case "/api/v1": + response = &metav1.APIResourceList{ + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "pods", + SingularName: "pod", + ShortNames: []string{"po"}, + Namespaced: true, + Kind: "Pod", + }, + }, + } + case "/api": + response = &metav1.APIVersions{ + Versions: []string{ + "v1", + }, + } + case "/apis": + response = &metav1.APIGroupList{ + Groups: []metav1.APIGroup{}, + } + + case "/api/v1/namespaces/toolchain-host-operator/pods/cheesecake": + response = corev1.Pod{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "toolchain-host-operator", + Name: "cheesecake", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "default", + }, + }, + }, + Status: corev1.PodStatus{ + Phase: "Running", + }, + } + case "/api/v1/namespaces/toolchain-host-operator/pods/cheesecake/log": + response = "some content" + default: + t.Errorf("not found: %s %s\n", req.Method, req.URL) + w.WriteHeader(http.StatusNotFound) + return + } + default: + t.Errorf("unexpected request: %s %s\n", req.Method, req.URL) + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(response) + if err != nil { + t.Errorf("unexpected encoding error: %v", err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(output) // nolint: errcheck + })) +} diff --git a/pkg/cmd/promote_space.go b/pkg/cmd/promote_space.go new file mode 100644 index 0000000..578f26f --- /dev/null +++ b/pkg/cmd/promote_space.go @@ -0,0 +1,60 @@ +package cmd + +import ( + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" +) + +func NewPromoteSpaceCmd() *cobra.Command { + return &cobra.Command{ + Use: "promote-space ", + Short: "Promote a Space to the given tier", + Long: `Promote a Space to the given tier. There are two expected +parameters - first one is Space name and second is the name of the target NSTemplateTier that the space should be promoted to`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return PromoteSpace(ctx, args[0], args[1]) + }, + } +} + +func PromoteSpace(ctx *clicontext.CommandContext, spaceName, targetTier string) error { + return client.PatchSpace(ctx, spaceName, func(space *toolchainv1alpha1.Space) (bool, error) { + + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return false, err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return false, err + } + + // verify the NSTemplateTier exists + if _, err := client.GetNSTemplateTier(cfg, cl, targetTier); err != nil { + return false, err + } + + if err := ctx.PrintObject(space, "Space to be promoted"); err != nil { + return false, err + } + + confirmation := ctx.AskForConfirmation(ioutils.WithMessagef( + "promote the Space '%s' to the '%s' tier?", + spaceName, targetTier)) + + if confirmation { + // set target tier + space.Spec.TierName = targetTier + return true, nil + } + return false, nil + }, "Successfully promoted Space") +} diff --git a/pkg/cmd/promote_space_test.go b/pkg/cmd/promote_space_test.go new file mode 100644 index 0000000..c1da85a --- /dev/null +++ b/pkg/cmd/promote_space_test.go @@ -0,0 +1,131 @@ +package cmd_test + +import ( + "context" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPromoteSpaceCmdWhenAnswerIsY(t *testing.T) { + // given + space := newSpace() + newClient, newRESTClient, fakeClient := NewFakeClients(t, space, newNSTemplateTier("advanced")) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.PromoteSpace(ctx, space.Name, "advanced") + + // then + require.NoError(t, err) + space.Spec.TierName = "advanced" // space should be changed to advanced tier + assertSpaceSpec(t, fakeClient, space) + output := term.Output() + assert.Contains(t, output, "promote the Space 'testspace' to the 'advanced' tier?") + assert.Contains(t, output, "Successfully promoted Space") + assert.NotContains(t, output, "cool-token") +} + +func TestPromoteSpaceCmdWhenAnswerIsN(t *testing.T) { + // given + space := newSpace() + newClient, newRESTClient, fakeClient := NewFakeClients(t, space, newNSTemplateTier("advanced")) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.PromoteSpace(ctx, space.Name, "advanced") + + // then + require.NoError(t, err) + assertSpaceSpec(t, fakeClient, space) // space should be unchanged + output := term.Output() + assert.Contains(t, output, "promote the Space 'testspace' to the 'advanced' tier?") + assert.NotContains(t, output, "Successfully promoted Space") + assert.NotContains(t, output, "cool-token") +} + +func TestPromoteSpaceCmdWhenSpaceNotFound(t *testing.T) { + // given + space := newSpace() + newClient, newRESTClient, fakeClient := NewFakeClients(t, space, newNSTemplateTier("advanced")) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.PromoteSpace(ctx, "another", "advanced") // attempt to promote a space that does not exist + + // then + require.EqualError(t, err, "spaces.toolchain.dev.openshift.com \"another\" not found") + assertSpaceSpec(t, fakeClient, space) // unrelated space should be unchanged + output := term.Output() + assert.NotContains(t, output, "promote the Space 'another' to the 'advanced' tier?") + assert.NotContains(t, output, "Successfully promoted Space") + assert.NotContains(t, output, "cool-token") +} + +func TestPromoteSpaceCmdWhenNSTemplateTierNotFound(t *testing.T) { + // given + space := newSpace() + newClient, newRESTClient, fakeClient := NewFakeClients(t, space) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.PromoteSpace(ctx, space.Name, "advanced") + + // then + require.EqualError(t, err, "nstemplatetiers.toolchain.dev.openshift.com \"advanced\" not found") + assertSpaceSpec(t, fakeClient, space) // space should be unchanged + output := term.Output() + assert.NotContains(t, output, "promote the Space 'another' to the 'advanced' tier?") + assert.NotContains(t, output, "Successfully promoted Space") + assert.NotContains(t, output, "cool-token") +} + +func newNSTemplateTier(name string) *toolchainv1alpha1.NSTemplateTier { + nsTemplateTier := &toolchainv1alpha1.NSTemplateTier{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: test.HostOperatorNs, + }, + Spec: toolchainv1alpha1.NSTemplateTierSpec{}, + } + return nsTemplateTier +} + +func newSpace() *toolchainv1alpha1.Space { + space := &toolchainv1alpha1.Space{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testspace", + Namespace: test.HostOperatorNs, + Labels: map[string]string{ + toolchainv1alpha1.SpaceCreatorLabelKey: "testcreator", + }, + }, + Spec: toolchainv1alpha1.SpaceSpec{ + TierName: "base", + }, + } + return space +} + +func assertSpaceSpec(t *testing.T, fakeClient *test.FakeClient, expectedSpace *toolchainv1alpha1.Space) { + updatedSpace := &toolchainv1alpha1.Space{} + err := fakeClient.Get(context.TODO(), test.NamespacedName(expectedSpace.Namespace, expectedSpace.Name), updatedSpace) + require.NoError(t, err) + assert.Equal(t, expectedSpace.Spec, updatedSpace.Spec) +} diff --git a/pkg/cmd/promote_user.go b/pkg/cmd/promote_user.go new file mode 100644 index 0000000..1621675 --- /dev/null +++ b/pkg/cmd/promote_user.go @@ -0,0 +1,60 @@ +package cmd + +import ( + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" +) + +func NewPromoteUserCmd() *cobra.Command { + return &cobra.Command{ + Use: "promote-user ", + Short: "Promote a user for the given MasterUserRecord resource to the given user tier", + Long: `Promote a user for the given MasterUserRecord to the given user tier. There are two expected +parameters - first one is MasterUserRecord name and second is the name of the target tier that the user should be promoted to`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return PromoteUser(ctx, args[0], args[1]) + }, + } +} + +func PromoteUser(ctx *clicontext.CommandContext, murName, targetTier string) error { + return client.PatchMasterUserRecord(ctx, murName, func(mur *toolchainv1alpha1.MasterUserRecord) (bool, error) { + + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return false, err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return false, err + } + + // verify user tier exists + if _, err := client.GetUserTier(cfg, cl, targetTier); err != nil { + return false, err + } + + if err := ctx.PrintObject(mur, "MasterUserRecord to be promoted"); err != nil { + return false, err + } + + confirmation := ctx.AskForConfirmation(ioutils.WithMessagef( + "promote the MasterUserRecord '%s' to the '%s' user tier?", + murName, targetTier)) + + if confirmation { + // set target tier + mur.Spec.TierName = targetTier + return true, nil + } + return false, nil + }, "Successfully promoted MasterUserRecord") +} diff --git a/pkg/cmd/promote_user_test.go b/pkg/cmd/promote_user_test.go new file mode 100644 index 0000000..452af74 --- /dev/null +++ b/pkg/cmd/promote_user_test.go @@ -0,0 +1,118 @@ +package cmd_test + +import ( + "context" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/codeready-toolchain/toolchain-common/pkg/test/masteruserrecord" + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPromoteUserCmdWhenAnswerIsY(t *testing.T) { + // given + mur := masteruserrecord.NewMasterUserRecord(t, "testmur", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, mur, newUserTier("deactivate180")) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.PromoteUser(ctx, mur.Name, "deactivate180") + + // then + require.NoError(t, err) + mur.Spec.TierName = "deactivate180" // mur should be changed to deactivate180 tier + assertMasterUserRecordSpec(t, fakeClient, mur) + output := term.Output() + assert.Contains(t, output, "promote the MasterUserRecord 'testmur' to the 'deactivate180' user tier?") + assert.Contains(t, output, "Successfully promoted MasterUserRecord") + assert.NotContains(t, output, "cool-token") +} + +func TestPromoteUserCmdWhenAnswerIsN(t *testing.T) { + // given + mur := masteruserrecord.NewMasterUserRecord(t, "testmur", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, mur, newUserTier("deactivate180")) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("n") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.PromoteUser(ctx, mur.Name, "deactivate180") + + // then + require.NoError(t, err) + assertMasterUserRecordSpec(t, fakeClient, mur) // mur should be unchanged + output := term.Output() + assert.Contains(t, output, "promote the MasterUserRecord 'testmur' to the 'deactivate180' user tier?") + assert.NotContains(t, output, "Successfully promoted MasterUserRecord") + assert.NotContains(t, output, "cool-token") +} + +func TestPromoteUserCmdWhenMasterUserRecordNotFound(t *testing.T) { + // given + mur := masteruserrecord.NewMasterUserRecord(t, "testmur", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, mur, newUserTier("deactivate180")) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.PromoteUser(ctx, "another", "deactivate180") // attempt to promote a mur that does not exist + + // then + require.EqualError(t, err, "masteruserrecords.toolchain.dev.openshift.com \"another\" not found") + assertMasterUserRecordSpec(t, fakeClient, mur) // unrelated mur should be unchanged + output := term.Output() + assert.NotContains(t, output, "promote the MasterUserRecord 'another' to the 'deactivate180' user tier?") + assert.NotContains(t, output, "Successfully promoted MasterUserRecord") + assert.NotContains(t, output, "cool-token") +} + +func TestPromoteUserCmdWhenUserTierNotFound(t *testing.T) { + // given + mur := masteruserrecord.NewMasterUserRecord(t, "testmur", masteruserrecord.TierName("deactivate30")) + newClient, newRESTClient, fakeClient := NewFakeClients(t, mur) + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.PromoteUser(ctx, mur.Name, "deactivate180") + + // then + require.EqualError(t, err, "usertiers.toolchain.dev.openshift.com \"deactivate180\" not found") + assertMasterUserRecordSpec(t, fakeClient, mur) // mur should be unchanged + output := term.Output() + assert.NotContains(t, output, "promote the MasterUserRecord 'another' to the 'deactivate180' user tier?") + assert.NotContains(t, output, "Successfully promoted MasterUserRecord") + assert.NotContains(t, output, "cool-token") +} + +func assertMasterUserRecordSpec(t *testing.T, fakeClient *test.FakeClient, expectedMasterUserRecord *toolchainv1alpha1.MasterUserRecord) { + updatedMasterUserRecord := &toolchainv1alpha1.MasterUserRecord{} + err := fakeClient.Get(context.TODO(), test.NamespacedName(expectedMasterUserRecord.Namespace, expectedMasterUserRecord.Name), updatedMasterUserRecord) + require.NoError(t, err) + assert.Equal(t, expectedMasterUserRecord.Spec, updatedMasterUserRecord.Spec) +} + +func newUserTier(name string) *toolchainv1alpha1.UserTier { + userTier := &toolchainv1alpha1.UserTier{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: test.HostOperatorNs, + }, + Spec: toolchainv1alpha1.UserTierSpec{ + DeactivationTimeoutDays: 180, + }, + } + return userTier +} diff --git a/pkg/cmd/remove_space_users.go b/pkg/cmd/remove_space_users.go new file mode 100644 index 0000000..a16e243 --- /dev/null +++ b/pkg/cmd/remove_space_users.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "context" + "fmt" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/cmd/flags" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" +) + +func NewRemoveSpaceUsersCmd() *cobra.Command { + var spaceName string + var users []string + command := &cobra.Command{ + Use: "remove-space-users --space= --users=<\"masteruserrecord1 masteruserrecord2...\">", + Short: "Delete a SpaceBindings between the given Space and the given MasterUserRecords", + Long: `Delete SpaceBindings between the given Space and the given MasterUserRecords. The first parameter is the name of the Space followed by +one or more users specified by their MasterUserRecord name.`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + + return RemoveSpaceUsers(ctx, spaceName, users) + }, + } + command.Flags().StringVarP(&spaceName, "space", "s", "", "the name of the space to remove users from") + flags.MustMarkRequired(command, "space") + command.Flags().StringArrayVarP(&users, "users", "u", []string{}, "the masteruserrecord names of the users to remove from the space") + flags.MustMarkRequired(command, "users") + + return command +} + +func RemoveSpaceUsers(ctx *clicontext.CommandContext, spaceName string, usersToRemove []string) error { + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) // uses the same token as add-space-users + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + + // get Space + ctx.Println("Checking space...") + space, err := client.GetSpace(cl, cfg.SandboxNamespace, spaceName) + if err != nil { + return err + } + + // get SpaceBindings to delete + spaceBindingsToDelete := []*toolchainv1alpha1.SpaceBinding{} + for _, murName := range usersToRemove { + sbs, err := client.ListSpaceBindings(cl, cfg.SandboxNamespace, client.ForSpace(spaceName), client.ForMasterUserRecord(murName)) + if err != nil { + return err + } + if len(sbs) == 0 { + return fmt.Errorf("no SpaceBinding found for Space '%s' and MasterUserRecord '%s'", spaceName, murName) + } + for i := range sbs { + spaceBindingsToDelete = append(spaceBindingsToDelete, &sbs[i]) + } + } + + // confirmation before SpaceBinding deletion + if err := ctx.PrintObject(space, "Space"); err != nil { + return err + } + confirmation := ctx.AskForConfirmation(ioutils.WithMessagef( + "remove users from the above Space?")) + if !confirmation { + return nil + } + + ctx.Println("Deleting SpaceBinding(s)...") + // delete SpaceBindings + for _, sb := range spaceBindingsToDelete { + if err := cl.Delete(context.TODO(), sb); err != nil { + return err + } + } + + ctx.Printlnf("\nAll SpaceBinding(s) successfully deleted") + return nil +} diff --git a/pkg/cmd/remove_space_users_test.go b/pkg/cmd/remove_space_users_test.go new file mode 100644 index 0000000..53c4d4d --- /dev/null +++ b/pkg/cmd/remove_space_users_test.go @@ -0,0 +1,206 @@ +package cmd_test + +import ( + "context" + "fmt" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/spacebinding" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/codeready-toolchain/toolchain-common/pkg/test/masteruserrecord" + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestRemoveSpaceUsers(t *testing.T) { + + t.Run("when answer is Y", func(t *testing.T) { + t.Run("when both spacebindings are deleted", func(t *testing.T) { + // given + space := newSpace() + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + sb1 := spacebinding.NewSpaceBinding(mur1, space, "alice") + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + sb2 := spacebinding.NewSpaceBinding(mur2, space, "bob") + newClient, newRESTClient, fakeClient := NewFakeClients(t, space, sb1, sb2) + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.RemoveSpaceUsers(ctx, "testspace", []string{"alice", "bob"}) + + // then + require.NoError(t, err) + output := term.Output() + assertSpaceBindingsRemaining(t, fakeClient, []string{}) // should be deleted + assert.Contains(t, output, "Are you sure that you want to remove users from the above Space?") + assert.Contains(t, output, "SpaceBinding(s) successfully deleted") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when only one spacebinding is deleted", func(t *testing.T) { + // given + space := newSpace() + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + sb1 := spacebinding.NewSpaceBinding(mur1, space, "alice") + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + sb2 := spacebinding.NewSpaceBinding(mur2, space, "bob") + newClient, newRESTClient, fakeClient := NewFakeClients(t, space, sb1, sb2) + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.RemoveSpaceUsers(ctx, "testspace", []string{"alice"}) + + // then + require.NoError(t, err) + output := term.Output() + assertSpaceBindingsRemaining(t, fakeClient, []string{"bob"}) // one should remain + assert.Contains(t, output, "Are you sure that you want to remove users from the above Space?") + assert.Contains(t, output, "SpaceBinding(s) successfully deleted") + assert.NotContains(t, output, "cool-token") + }) + }) + + t.Run("when answer is N", func(t *testing.T) { + // given + space := newSpace() + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + sb1 := spacebinding.NewSpaceBinding(mur1, space, "alice") + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + sb2 := spacebinding.NewSpaceBinding(mur2, space, "bob") + newClient, newRESTClient, fakeClient := NewFakeClients(t, space, sb1, sb2) + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("N") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.RemoveSpaceUsers(ctx, "testspace", []string{"alice", "bob"}) + + // then + require.NoError(t, err) + output := term.Output() + assertSpaceBindingsRemaining(t, fakeClient, []string{"alice", "bob"}) // should not be deleted + assert.Contains(t, output, "Are you sure that you want to remove users from the above Space?") + assert.NotContains(t, output, "SpaceBinding(s) successfully deleted") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when space not found", func(t *testing.T) { + // given + space := newSpace() + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + sb1 := spacebinding.NewSpaceBinding(mur1, space, "alice") + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + sb2 := spacebinding.NewSpaceBinding(mur2, space, "bob") + newClient, newRESTClient, fakeClient := NewFakeClients(t, sb1, sb2) // no space + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("N") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.RemoveSpaceUsers(ctx, "testspace", []string{"alice", "bob"}) + + // then + require.EqualError(t, err, `spaces.toolchain.dev.openshift.com "testspace" not found`) + output := term.Output() + assertSpaceBindingsRemaining(t, fakeClient, []string{"alice", "bob"}) // should not be deleted + assert.NotContains(t, output, "Are you sure that you want to remove users from the above Space?") + assert.NotContains(t, output, "SpaceBinding(s) successfully deleted") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("when mur not found", func(t *testing.T) { + // given + space := newSpace() + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + sb1 := spacebinding.NewSpaceBinding(mur1, space, "alice") + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + sb2 := spacebinding.NewSpaceBinding(mur2, space, "bob") + newClient, newRESTClient, fakeClient := NewFakeClients(t, space, sb1, sb2) + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("N") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.RemoveSpaceUsers(ctx, "testspace", []string{"alice", "notexist"}) + + // then + require.EqualError(t, err, `no SpaceBinding found for Space 'testspace' and MasterUserRecord 'notexist'`) + output := term.Output() + assertSpaceBindingsRemaining(t, fakeClient, []string{"alice", "bob"}) // should not be deleted + assert.NotContains(t, output, "Are you sure that you want to remove users from the above Space?") + assert.NotContains(t, output, "SpaceBinding(s) successfully deleted") + assert.NotContains(t, output, "cool-token") + }) + + t.Run("client get error", func(t *testing.T) { + // given + space := newSpace() + mur1 := masteruserrecord.NewMasterUserRecord(t, "alice", masteruserrecord.TierName("deactivate30")) + sb1 := spacebinding.NewSpaceBinding(mur1, space, "alice") + mur2 := masteruserrecord.NewMasterUserRecord(t, "bob", masteruserrecord.TierName("deactivate30")) + sb2 := spacebinding.NewSpaceBinding(mur2, space, "bob") + newClient, newRESTClient, fakeClient := NewFakeClients(t, space, sb1, sb2) + fakeClient.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("client error") + } + + SetFileConfig(t, Host()) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.RemoveSpaceUsers(ctx, "testspace", []string{"alice", "bob"}) + + // then + require.EqualError(t, err, "client error") + output := term.Output() + assertSpaceBindingsRemaining(t, fakeClient, []string{"alice", "bob"}) + assert.NotContains(t, output, "Are you sure that you want to remove users from the above Space?") + assert.NotContains(t, output, "SpaceBinding(s) successfully deleted") + assert.NotContains(t, output, "cool-token") + }) +} + +func assertSpaceBindingsRemaining(t *testing.T, fakeClient *test.FakeClient, expectedMurs []string) { + + // list all SpaceBindings for the given space + allSpaceBindings := &toolchainv1alpha1.SpaceBindingList{} + err := fakeClient.List(context.TODO(), allSpaceBindings, runtimeclient.InNamespace(test.HostOperatorNs), runtimeclient.MatchingLabels{ + toolchainv1alpha1.SpaceBindingSpaceLabelKey: "testspace", + }) + require.NoError(t, err) + + // verify the expected number of SpaceBindings were created + assert.Len(t, allSpaceBindings.Items, len(expectedMurs)) + + // check that any expected MURs still have SpaceBindings + var checked int + for _, expectedMur := range expectedMurs { + for _, sb := range allSpaceBindings.Items { + if sb.Labels[toolchainv1alpha1.SpaceBindingMasterUserRecordLabelKey] == expectedMur { + require.Equal(t, expectedMur, sb.Labels[toolchainv1alpha1.SpaceBindingMasterUserRecordLabelKey]) + require.Equal(t, expectedMur, sb.Spec.MasterUserRecord) + require.Equal(t, "testspace", sb.Spec.Space) + checked++ + } + } + } + if checked != len(expectedMurs) { + require.Fail(t, "some expected murs were not found") + } +} diff --git a/pkg/cmd/retarget.go b/pkg/cmd/retarget.go new file mode 100644 index 0000000..84d58a4 --- /dev/null +++ b/pkg/cmd/retarget.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "fmt" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/ghodss/yaml" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + errs "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func NewRetargetCmd() *cobra.Command { + return &cobra.Command{ + Use: "retarget ", + Short: "Retarget the Space with the given name to the given target cluster", + Long: `Retargets the given Space by patching the Space.Spec.TargetCluster field to the name of the given target cluster`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return Retarget(ctx, args[0], args[1]) + }, + } +} + +func Retarget(ctx *clicontext.CommandContext, spaceName, targetCluster string) error { + hostClusterConfig, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + hostClusterClient, err := ctx.NewClient(hostClusterConfig.Token, hostClusterConfig.ServerAPI) + if err != nil { + return err + } + + // note: view toolchain role on the member cluster is good enough for retargeting since the retarget role is mainly for modifying the Space on the host + memberClusterConfig, err := configuration.LoadClusterConfig(ctx, targetCluster) + if err != nil { + return err + } + + space, err := client.GetSpace(hostClusterClient, hostClusterConfig.SandboxNamespace, spaceName) + if err != nil { + return err + } + + // let's get the creator + creator := space.Labels[toolchainv1alpha1.SpaceCreatorLabelKey] + if creator == "" { + return fmt.Errorf("spaces without the creator label are not supported") + } + userSignup, err := client.GetUserSignup(hostClusterClient, hostClusterConfig.SandboxNamespace, creator) + if err != nil { + return err + } + + if space.Spec.TargetCluster == memberToolchainClusterName(memberClusterConfig) { + return fmt.Errorf("the Space '%s' is already targeted to cluster '%s'", spaceName, targetCluster) + } + + // target cluster must have 'member' cluster type + if memberClusterConfig.ClusterType != configuration.Member { + return fmt.Errorf("expected target cluster to have clusterType '%s', actual: '%s'", configuration.Member, memberClusterConfig.ClusterType) + } + + // print Space before prompt + if err := ctx.PrintObject(space, "Space to be retargeted"); err != nil { + return err + } + + // and the owner (creator) + spec, err := yaml.Marshal(userSignup.Spec) + if err != nil { + return errs.Wrapf(err, "unable to unmarshal UserSignup.Spec") + } + ctx.PrintContextSeparatorWithBodyf(string(spec), "Owned (created) by UserSignup '%s' with spec", userSignup.Name) + + // prompt for confirmation to proceed + confirmationMsg := ioutils.WithDangerZoneMessagef( + "deletion of all related namespaces and all related data", + "retarget the Space '%s' owned (created) by UserSignup '%s' to cluster '%s'?", + spaceName, userSignup.Name, targetCluster) + + if confirmed := ctx.AskForConfirmation(confirmationMsg); !confirmed { + return nil + } + + err = client.PatchSpace(ctx, space.Name, func(space *toolchainv1alpha1.Space) (bool, error) { + space.Spec.TargetCluster = memberToolchainClusterName(memberClusterConfig) + return true, nil + }, "Space has been patched to target cluster "+targetCluster) + if err != nil { + return errs.Wrapf(err, "failed to retarget Space '%s'", spaceName) + } + + ctx.Printlnf("\nSpace has been retargeted to cluster " + targetCluster) + return nil +} + +func memberToolchainClusterName(memberClusterConfig configuration.ClusterConfig) string { + return "member-" + memberClusterConfig.ServerName +} diff --git a/pkg/cmd/retarget_test.go b/pkg/cmd/retarget_test.go new file mode 100644 index 0000000..d5cfd7a --- /dev/null +++ b/pkg/cmd/retarget_test.go @@ -0,0 +1,174 @@ +package cmd_test + +import ( + "context" + "fmt" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + testspace "github.com/codeready-toolchain/toolchain-common/pkg/test/space" + testusersignup "github.com/codeready-toolchain/toolchain-common/pkg/test/usersignup" + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestRetarget(t *testing.T) { + userSignup := testusersignup.NewUserSignup(testusersignup.WithName("john")) + + t.Run("retarget success", func(t *testing.T) { + // given + term := NewFakeTerminalWithResponse("y") + space := testspace.NewSpace(test.HostOperatorNs, "john-dev", testspace.WithCreatorLabel("john")) + newClient, newRESTClient, fakeClient := prepareRetargetSpace(t, space, userSignup) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Retarget(ctx, space.Name, "member2") + + // then + require.NoError(t, err) + testspace.AssertThatSpace(t, space.Namespace, space.Name, fakeClient).HasSpecTargetCluster("member-m2.devcluster.openshift.com") + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), fmt.Sprintf("Are you sure that you want to retarget the Space '%s' owned (created) by UserSignup '%s' to cluster 'member2'?", space.Name, userSignup.Name)) + assert.Contains(t, term.Output(), "Space to be retargeted") + assert.Contains(t, term.Output(), fmt.Sprintf("Owned (created) by UserSignup '%s' with spec", userSignup.Name)) + assert.Contains(t, term.Output(), "Space has been patched to target cluster member2") + assert.Contains(t, term.Output(), "Space has been retargeted to cluster member2") + assert.NotContains(t, term.Output(), "cool-token") + }) + + t.Run("retarget fail", func(t *testing.T) { + t.Run("no space found", func(t *testing.T) { + // given + term := NewFakeTerminalWithResponse("y") + newClient, newRESTClient, _ := prepareRetargetSpace(t) // no usersignup created + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Retarget(ctx, "space-that-doesnt-exist", "member1") + + // then + require.EqualError(t, err, `spaces.toolchain.dev.openshift.com "space-that-doesnt-exist" not found`) + }) + + t.Run("space already targeted to the provided target cluster", func(t *testing.T) { + // given + term := NewFakeTerminalWithResponse("y") + space := testspace.NewSpace(test.HostOperatorNs, "john-dev", testspace.WithCreatorLabel("john"), testspace.WithSpecTargetCluster("member-m2.devcluster.openshift.com")) + newClient, newRESTClient, _ := prepareRetargetSpace(t, space, userSignup) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Retarget(ctx, space.Name, "member2") + + // then + require.EqualError(t, err, fmt.Sprintf(`the Space '%s' is already targeted to cluster '%s'`, space.Name, "member2")) + }) + + t.Run("failed to get member cluster config", func(t *testing.T) { + // given + term := NewFakeTerminalWithResponse("y") + space := testspace.NewSpace(test.HostOperatorNs, "john-dev") + newClient, newRESTClient, _ := prepareRetargetSpace(t, space) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Retarget(ctx, space.Name, "non-existent-member") // bad member name + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "the provided cluster-name 'non-existent-member' is not present in your sandbox.yaml file") + }) + + t.Run("setting target cluster failed", func(t *testing.T) { + // given + term := NewFakeTerminalWithResponse("y") + space := testspace.NewSpace(test.HostOperatorNs, "john-dev", testspace.WithCreatorLabel("john")) + newClient, newRESTClient, fakeClient := prepareRetargetSpace(t, space, userSignup) + fakeClient.MockPatch = func(ctx context.Context, obj runtimeclient.Object, patch runtimeclient.Patch, opts ...runtimeclient.PatchOption) error { + if testSignup, ok := obj.(*toolchainv1alpha1.Space); ok { + if testSignup.Spec.TargetCluster != "" { + return fmt.Errorf("fail target cluster") + } + } + return fakeClient.Client.Patch(ctx, obj, patch, opts...) + } + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Retarget(ctx, space.Name, "member2") + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("failed to retarget Space '%s'", space.Name)) + }) + + t.Run("space without owner label", func(t *testing.T) { + // given + term := NewFakeTerminalWithResponse("y") + space := testspace.NewSpace(test.HostOperatorNs, "john-dev") + newClient, newRESTClient, _ := prepareRetargetSpace(t, space, userSignup) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Retarget(ctx, space.Name, "member2") + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "spaces without the creator label are not supported") + }) + + t.Run("usersignup not found", func(t *testing.T) { + // given + term := NewFakeTerminalWithResponse("y") + space := testspace.NewSpace(test.HostOperatorNs, "john-dev", testspace.WithCreatorLabel("john")) + newClient, newRESTClient, _ := prepareRetargetSpace(t, space) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Retarget(ctx, space.Name, "member2") + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), `usersignups.toolchain.dev.openshift.com "john" not found`) + }) + }) + + t.Run("user responds no", func(t *testing.T) { + // given + term := NewFakeTerminalWithResponse("n") + space := testspace.NewSpace(test.HostOperatorNs, "john-dev", testspace.WithCreatorLabel("john"), testspace.WithSpecTargetCluster("member-m1.devcluster.openshift.com")) + newClient, newRESTClient, fakeClient := prepareRetargetSpace(t, space, userSignup) + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Retarget(ctx, space.Name, "member2") + + // then + require.NoError(t, err) + testspace.AssertThatSpace(t, space.Namespace, space.Name, fakeClient).HasSpecTargetCluster("member-m1.devcluster.openshift.com") + assert.Contains(t, term.Output(), "!!! DANGER ZONE !!!") + assert.Contains(t, term.Output(), fmt.Sprintf("Are you sure that you want to retarget the Space '%s' owned (created) by UserSignup '%s' to cluster 'member2'?", space.Name, userSignup.Name)) + assert.Contains(t, term.Output(), "Space to be retargeted") + assert.Contains(t, term.Output(), fmt.Sprintf("Owned (created) by UserSignup '%s' with spec", userSignup.Name)) + assert.NotContains(t, term.Output(), "Space has been patched to target cluster member2") + assert.NotContains(t, term.Output(), "Space has been retargeted to cluster member2") + assert.NotContains(t, term.Output(), "cool-token") + }) +} + +func prepareRetargetSpace(t *testing.T, initObjs ...runtime.Object) (clicontext.NewClientFunc, clicontext.NewRESTClientFunc, *test.FakeClient) { + newClient, newRESTClient, fakeClient := NewFakeClients(t, initObjs...) + SetFileConfig(t, + Host(), + Member(ClusterName("member1"), ServerName("m1.devcluster.openshift.com")), + Member(ClusterName("member2"), ServerName("m2.devcluster.openshift.com"))) + + return newClient, newRESTClient, fakeClient +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go new file mode 100644 index 0000000..22d37d4 --- /dev/null +++ b/pkg/cmd/root.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "crypto/tls" + "fmt" + "net/http" + "os" + + "github.com/kubesaw/ksctl/pkg/cmd/adm" + "github.com/kubesaw/ksctl/pkg/configuration" + "github.com/kubesaw/ksctl/pkg/version" + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = NewRootCmd() + +func NewRootCmd() *cobra.Command { + return &cobra.Command{ + Use: "sandbox-cli", + Short: "Dev Sandbox CLI", + Long: `Dev Sandbox CLI helps you to manage Toolchain resources in Dev Sandbox instances`, + Version: version.NewMessage(), + } +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVar(&configuration.ConfigFileFlag, "config", "", "config file (default is $HOME/.sandbox.yaml)") + rootCmd.PersistentFlags().BoolVarP(&configuration.Verbose, "verbose", "v", false, "print extra info/debug messages") + + // commands with go runtime client + rootCmd.AddCommand(NewAddSpaceUsersCmd()) + rootCmd.AddCommand(NewApproveCmd()) + rootCmd.AddCommand(NewBanCmd()) + rootCmd.AddCommand(NewDeactivateCmd()) + rootCmd.AddCommand(NewPromoteSpaceCmd()) + rootCmd.AddCommand(NewPromoteUserCmd()) + rootCmd.AddCommand(NewRemoveSpaceUsersCmd()) + rootCmd.AddCommand(NewRetargetCmd()) + rootCmd.AddCommand(NewStatusCmd()) + rootCmd.AddCommand(NewGdprDeleteCmd()) + rootCmd.AddCommand(NewCreateSocialEventCmd()) + rootCmd.AddCommand(NewGetCmd()) + rootCmd.AddCommand(NewLogsCmd()) + rootCmd.AddCommand(NewDescribeCmd()) + rootCmd.AddCommand(NewDisableUserCmd()) + + // administrative commands + rootCmd.AddCommand(adm.NewAdmCmd()) + + // also, by default, we're configuring the underlying http.Client to accept insecured connections. + // but gopkg.in/h2non/gock.v1 may change the client's Transport to intercept the requests. + http.DefaultClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // nolint: gosec + }, + } +} diff --git a/pkg/cmd/status.go b/pkg/cmd/status.go new file mode 100644 index 0000000..96ef06b --- /dev/null +++ b/pkg/cmd/status.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "context" + "fmt" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/condition" + "github.com/kubesaw/ksctl/pkg/client" + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/types" +) + +func NewStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show ToolchainStatus CR", + Long: `Show the ToolchainStatus CR`, + Args: cobra.MaximumNArgs(0), + RunE: func(cmd *cobra.Command, _ []string) error { + term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout) + ctx := clicontext.NewCommandContext(term, client.DefaultNewClient, client.DefaultNewRESTClient) + return Status(ctx) + }, + } +} + +func Status(ctx *clicontext.CommandContext) error { + cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName) + if err != nil { + return err + } + cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI) + if err != nil { + return err + } + namespacedName := types.NamespacedName{ + Namespace: cfg.SandboxNamespace, + Name: "toolchain-status", + } + status := &toolchainv1alpha1.ToolchainStatus{} + if err := cl.Get(context.TODO(), namespacedName, status); err != nil { + return err + } + + cond, exists := condition.FindConditionByType(status.Status.Conditions, toolchainv1alpha1.ConditionReady) + title := "Current ToolchainStatus CR - " + if exists { + title += fmt.Sprintf("Condition: %s, Status: %s, Reason: %s", cond.Type, cond.Status, cond.Reason) + if cond.Message != "" { + title += fmt.Sprintf(", Message: %s", cond.Message) + } + } else { + title += "Condition Ready wasn't found!" + } + return ctx.PrintObject(status, title) +} diff --git a/pkg/cmd/status_test.go b/pkg/cmd/status_test.go new file mode 100644 index 0000000..0efe9b6 --- /dev/null +++ b/pkg/cmd/status_test.go @@ -0,0 +1,143 @@ +package cmd_test + +import ( + "context" + "fmt" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/cmd" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestStatusCmdWhenIsReady(t *testing.T) { + // given + toolchainStatus := NewToolchainStatus(ToBeReady()) + newClient, newRESTClient, _ := NewFakeClients(t, toolchainStatus) + SetFileConfig(t, Host()) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Status(ctx) + + // then + require.NoError(t, err) + output := term.Output() + assert.Contains(t, output, "Current ToolchainStatus CR - Condition: Ready, Status: True, Reason: AllComponentsReady") + assert.NotContains(t, output, "cool-token") +} + +func TestStatusCmdWhenIsNotReady(t *testing.T) { + // given + toolchainStatus := NewToolchainStatus(ToBeNotReady()) + newClient, newRESTClient, _ := NewFakeClients(t, toolchainStatus) + SetFileConfig(t, Host()) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Status(ctx) + + // then + require.NoError(t, err) + output := term.Output() + assert.Contains(t, output, "Current ToolchainStatus CR - Condition: Ready, Status: False, Reason: ComponentsNotReady, Message: components not ready: [members]") + assert.NotContains(t, output, "cool-token") +} + +func TestStatusCmdWhenConditionNotFound(t *testing.T) { + // given + toolchainStatus := NewToolchainStatus(toolchainv1alpha1.Condition{}) + newClient, newRESTClient, _ := NewFakeClients(t, toolchainStatus) + SetFileConfig(t, Host()) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Status(ctx) + + // then + require.NoError(t, err) + output := term.Output() + assert.Contains(t, output, "Current ToolchainStatus CR - Condition Ready wasn't found!") + assert.NotContains(t, output, "cool-token") +} + +func TestStatusCmdWithInsufficientPermissions(t *testing.T) { + // given + toolchainStatus := NewToolchainStatus(toolchainv1alpha1.Condition{}) + newClient, newRESTClient, _ := NewFakeClients(t, toolchainStatus) + SetFileConfig(t, Host(NoToken())) + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Status(ctx) + + // then + require.Error(t, err) + output := term.Output() + assert.NotContains(t, output, "Current ToolchainStatus CR") + assert.NotContains(t, output, "cool-token") +} + +func TestStatusCmdWhenGetFailed(t *testing.T) { + // given + toolchainStatus := NewToolchainStatus(toolchainv1alpha1.Condition{}) + newClient, newRESTClient, fakeClient := NewFakeClients(t, toolchainStatus) + SetFileConfig(t, Host()) + fakeClient.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("some error") + } + term := NewFakeTerminal() + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + err := cmd.Status(ctx) + + // then + require.Error(t, err) + output := term.Output() + assert.NotContains(t, output, "Current ToolchainStatus CR") + assert.NotContains(t, output, "cool-token") +} + +func NewToolchainStatus(cond toolchainv1alpha1.Condition) *toolchainv1alpha1.ToolchainStatus { + return &toolchainv1alpha1.ToolchainStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: "toolchain-status", + Namespace: test.HostOperatorNs, + }, + Status: toolchainv1alpha1.ToolchainStatusStatus{ + Conditions: []toolchainv1alpha1.Condition{ + cond, + }, + }, + } +} + +func ToBeReady() toolchainv1alpha1.Condition { + return toolchainv1alpha1.Condition{ + Type: toolchainv1alpha1.ConditionReady, + Status: corev1.ConditionTrue, + Reason: toolchainv1alpha1.ToolchainStatusAllComponentsReadyReason, + } +} + +func ToBeNotReady() toolchainv1alpha1.Condition { + return toolchainv1alpha1.Condition{ + Type: toolchainv1alpha1.ConditionReady, + Status: corev1.ConditionFalse, + Reason: toolchainv1alpha1.ToolchainStatusComponentsNotReadyReason, + Message: "components not ready: [members]", + } +} diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go new file mode 100644 index 0000000..29857cd --- /dev/null +++ b/pkg/configuration/configuration.go @@ -0,0 +1,231 @@ +package configuration + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kubesaw/ksctl/pkg/ioutils" + "github.com/kubesaw/ksctl/pkg/utils" + + "github.com/mitchellh/go-homedir" + errs "github.com/pkg/errors" + "gopkg.in/yaml.v3" +) + +var ( + ConfigFileFlag string + Verbose bool +) + +type SandboxUserConfig struct { + ClusterAccessDefinitions `yaml:",inline"` + Name string `yaml:"name"` +} + +// Load reads in config file and ENV variables if set. +func Load(term ioutils.Terminal) (SandboxUserConfig, string, error) { + path := ConfigFileFlag + if path == "" { + // Find home directory. + home, err := homedir.Dir() + if err != nil { + return SandboxUserConfig{}, "", errs.Wrap(err, "unable to read home directory") + } + path = filepath.Join(home, ".sandbox.yaml") + } + + info, err := os.Stat(path) + if err != nil { + return SandboxUserConfig{}, "", errs.Wrapf(err, "unable to read the file '%s'", path) + } + if info.IsDir() { + return SandboxUserConfig{}, "", fmt.Errorf("the '%s' is not file but a directory", path) + } + + if Verbose { + term.Printlnf("Using config file: '%s'", path) + } + + bytes, err := os.ReadFile(path) + if err != nil { + return SandboxUserConfig{}, "", err + } + sandboxUserConfig := SandboxUserConfig{} + if err := yaml.Unmarshal(bytes, &sandboxUserConfig); err != nil { + return SandboxUserConfig{}, "", err + } + return sandboxUserConfig, path, nil +} + +const HostName = "host" + +type ClusterType string + +var Host ClusterType = "host" +var Member ClusterType = "member" +var ClusterTypes = []ClusterType{Host, Member} + +func (cluster ClusterType) String() string { + return string(cluster) +} + +func (cluster ClusterType) TheOtherType() ClusterType { + if cluster == Host { + return Member + } + return Host +} + +func (cluster ClusterType) AsSuffix(prefix string) string { + return fmt.Sprintf("%s-%s", prefix, cluster) +} + +type ClusterAccessDefinitions map[string]ClusterAccessDefinition + +type ClusterDefinition struct { + ClusterType ClusterType `yaml:"clusterType"` + ServerAPI string `yaml:"serverAPI"` + ServerName string `yaml:"serverName"` +} + +type ClusterAccessDefinition struct { + ClusterDefinition `yaml:",inline"` + Token string `yaml:"token"` +} + +type ClusterNamespaces map[string]string + +// LoadClusterAccessDefinition loads ClusterAccessDefinition object from the config file and checks that all required parameters are set +func LoadClusterAccessDefinition(term ioutils.Terminal, clusterName string) (ClusterAccessDefinition, error) { + sandboxUserConfig, _, err := Load(term) + if err != nil { + return ClusterAccessDefinition{}, err + } + return loadClusterAccessDefinition(sandboxUserConfig, clusterName) +} + +func loadClusterAccessDefinition(sandboxUserConfig SandboxUserConfig, clusterName string) (ClusterAccessDefinition, error) { + // try converted to camel case if kebab case is provided + clusterDef, ok := sandboxUserConfig.ClusterAccessDefinitions[utils.KebabToCamelCase(clusterName)] + if !ok { + // if not found, then also try original format (to cover situation when camel case is used) + if clusterDef, ok = sandboxUserConfig.ClusterAccessDefinitions[clusterName]; !ok { + return ClusterAccessDefinition{}, fmt.Errorf("the provided cluster-name '%s' is not present in your sandbox.yaml file. The available cluster names are\n"+ + "------------------------\n%s\n"+ + "------------------------", clusterName, strings.Join(getAllClusterNames(sandboxUserConfig), "\n")) + } + } + if clusterDef.ClusterType == "" { + return ClusterAccessDefinition{}, fmt.Errorf("sandbox command failed: 'cluster type' is not set for cluster '%s'", clusterName) + } + if clusterDef.ServerAPI == "" { + return ClusterAccessDefinition{}, fmt.Errorf("sandbox command failed: The server API is not set for the cluster %s", clusterName) + } + if clusterDef.ServerName == "" { + return ClusterAccessDefinition{}, fmt.Errorf("sandbox command failed: The server name is not set for the cluster %s", clusterName) + } + return clusterDef, nil +} + +func getAllClusterNames(config SandboxUserConfig) []string { + var clusterNames []string + for clusterName := range config.ClusterAccessDefinitions { + clusterNames = append(clusterNames, utils.CamelCaseToKebabCase(clusterName)) + } + return clusterNames +} + +// ClusterConfig contains all parameters of a cluster loaded from SandboxUserConfig +// plus all cluster names defined in the SandboxUserConfig +type ClusterConfig struct { + ClusterAccessDefinition + AllClusterNames []string + ClusterName string + Token string + SandboxNamespace string + PathToConfigFile string +} + +// LoadClusterConfig loads ClusterConfig object from the config file and checks that all required parameters are set +// as well as the token for the given name +func LoadClusterConfig(term ioutils.Terminal, clusterName string) (ClusterConfig, error) { + sandboxUserConfig, path, err := Load(term) + if err != nil { + return ClusterConfig{}, err + } + clusterDef, err := loadClusterAccessDefinition(sandboxUserConfig, clusterName) + if err != nil { + return ClusterConfig{}, err + } + if clusterDef.Token == "" { + return ClusterConfig{}, fmt.Errorf("sandbox command failed: the token in your sandbox.yaml file is missing") + } + var sandboxNamespace string + if clusterName == HostName { + sandboxNamespace = os.Getenv("HOST_OPERATOR_NAMESPACE") + if sandboxNamespace == "" { + sandboxNamespace = "toolchain-host-operator" + } + } else { + sandboxNamespace = os.Getenv("MEMBER_OPERATOR_NAMESPACE") + if sandboxNamespace == "" { + sandboxNamespace = "toolchain-member-operator" + } + } + + if Verbose { + term.Printlnf("Using '%s' configuration for '%s' cluster running at '%s' and in namespace '%s'\n", + clusterName, clusterDef.ServerName, clusterDef.ServerAPI, sandboxNamespace) + } + return ClusterConfig{ + ClusterAccessDefinition: clusterDef, + AllClusterNames: getAllClusterNames(sandboxUserConfig), + ClusterName: clusterName, + Token: clusterDef.Token, + SandboxNamespace: sandboxNamespace, + PathToConfigFile: path, + }, nil +} + +// GetServerParam returns the `--server=` param along with its actual value +func (c ClusterConfig) GetServerParam() string { + return "--server=" + c.ServerAPI +} + +// GetNamespaceParam returns the `--namespace=` param along with its actual value +func (c ClusterConfig) GetNamespaceParam() string { + return "--namespace=" + c.SandboxNamespace +} + +// ConfigurePath returns the path to the 'configure' directory, using the clusterConfigName arg if it's not empty, +// or the Host cluster's server name (even if the current config applies to a Member cluster) +func (c ClusterConfig) ConfigurePath(term ioutils.Terminal, clusterConfigName, component string) (string, error) { + return c.Path(term, clusterConfigName, "configure", component) +} + +// InstallPath returns the path to the 'install' directory, using the clusterConfigName arg if it's not empty, +// or the Host cluster's server name (even if the current config applies to a Member cluster) +func (c ClusterConfig) InstallPath(term ioutils.Terminal, clusterConfigName, component string) (string, error) { + return c.Path(term, clusterConfigName, "install", component) +} + +// Path returns the path to the directory for the given action, using the clusterConfigName arg if it's not empty, +// or the Host cluster's server name (even if the current config applies to a Member cluster) +func (c ClusterConfig) Path(term ioutils.Terminal, clusterConfigName, section, component string) (string, error) { + baseDir := c.ServerName + if c.ClusterType == Member { + // for member clusters, we use the associated host's serverName to retrieve the configuration + var err error + clusterDef, err := LoadClusterAccessDefinition(term, HostName) + if err != nil { + return "", err + } + baseDir = clusterDef.ServerName + } + if clusterConfigName != "" { + baseDir = clusterConfigName + } + return fmt.Sprintf("%s/%s/%s/%s", baseDir, section, c.ClusterType, component), nil +} diff --git a/pkg/configuration/configuration_test.go b/pkg/configuration/configuration_test.go new file mode 100644 index 0000000..09c04b1 --- /dev/null +++ b/pkg/configuration/configuration_test.go @@ -0,0 +1,350 @@ +package configuration_test + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/configuration" + . "github.com/kubesaw/ksctl/pkg/test" + "github.com/kubesaw/ksctl/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadClusterConfig(t *testing.T) { + testCases := []struct { + caseName string + transform func(string) string + }{ + {"kebab", utils.CamelCaseToKebabCase}, + {"camel", utils.KebabToCamelCase}, + } + for _, testCase := range testCases { + t.Run(fmt.Sprintf("when using %s case", testCase.caseName), func(t *testing.T) { + + for _, clusterConfigParam := range []ClusterDefinitionWithName{Host(), Member()} { + clusterConfigParam.ClusterName = testCase.transform(clusterConfigParam.ClusterName) + clusterName := clusterConfigParam.ClusterName + + t.Run("when values are set for "+clusterConfigParam.ClusterName, func(t *testing.T) { + + // reset after tests below finished + v := configuration.Verbose + defer func() { + configuration.Verbose = v + }() + + t.Run("with verbose logs", func(t *testing.T) { + // given + SetFileConfig(t, clusterConfigParam) + namespaceName := fmt.Sprintf("toolchain-%s-operator", clusterConfigParam.ClusterType) + term := NewFakeTerminal() + term.Tee(os.Stdout) + configuration.Verbose = true + + // when + cfg, err := configuration.LoadClusterConfig(term, clusterName) + + // then + require.NoError(t, err) + assert.Equal(t, namespaceName, cfg.SandboxNamespace) + assert.Equal(t, "--namespace="+namespaceName, cfg.GetNamespaceParam()) + assert.Equal(t, clusterConfigParam.ClusterType, cfg.ClusterType) + assert.Equal(t, "cool-token", cfg.Token) + assert.Equal(t, "https://cool-server.com", cfg.ServerAPI) + assert.Equal(t, "--server=https://cool-server.com", cfg.GetServerParam()) + assert.Equal(t, "cool-server.com", cfg.ServerName) + assert.Len(t, cfg.AllClusterNames, 1) + assert.Contains(t, cfg.AllClusterNames, utils.CamelCaseToKebabCase(clusterName)) + assert.True(t, strings.HasPrefix(cfg.PathToConfigFile, os.TempDir())) + assert.Contains(t, term.Output(), fmt.Sprintf("Using config file: '%s'", configuration.ConfigFileFlag)) + assert.Contains(t, term.Output(), fmt.Sprintf("Using '%s' configuration for '%s' cluster running at '%s' and in namespace '%s'", + cfg.ClusterName, cfg.ServerName, cfg.ServerAPI, cfg.SandboxNamespace)) + }) + + t.Run("without verbose logs", func(t *testing.T) { + // given + SetFileConfig(t, clusterConfigParam) + term := NewFakeTerminal() + configuration.Verbose = false + + // when + cfg, err := configuration.LoadClusterConfig(term, clusterName) + + // then + require.NoError(t, err) + // don't repeat assertions above, just check that logs do NOT contain the following messages + assert.NotContains(t, term.Output(), fmt.Sprintf("Using config file: '%s'", configuration.ConfigFileFlag)) + assert.NotContains(t, term.Output(), fmt.Sprintf("Using '%s' configuration for '%s' cluster running at '%s' and in namespace '%s'", + cfg.ClusterType, cfg.ServerName, cfg.ServerAPI, cfg.SandboxNamespace)) + }) + }) + } + + for _, clusterConfigParam := range []ClusterDefinitionWithName{Host(), Member()} { + clusterConfigParam.ClusterName = testCase.transform(clusterConfigParam.ClusterName) + clusterName := clusterConfigParam.ClusterName + + t.Run("when sandbox namespace is set via global variable for "+clusterConfigParam.ClusterName, func(t *testing.T) { + // given + restore := test.SetEnvVarAndRestore(t, strings.ToUpper(clusterConfigParam.ClusterType.String())+"_OPERATOR_NAMESPACE", "custom-namespace") + t.Cleanup(restore) + SetFileConfig(t, WithValues(clusterConfigParam)) + term := NewFakeTerminal() + + // when + cfg, err := configuration.LoadClusterConfig(term, clusterName) + + // then + require.NoError(t, err, "sandbox command failed: The sandbox namespace is not set for the cluster "+clusterConfigParam.ClusterName) + assert.Equal(t, "custom-namespace", cfg.SandboxNamespace) + }) + } + + for _, clusterConfigParam := range []ClusterDefinitionWithName{Host(), Member()} { + clusterConfigParam.ClusterName = testCase.transform(clusterConfigParam.ClusterName) + clusterName := clusterConfigParam.ClusterName + + t.Run("when clusterType is not set for "+clusterConfigParam.ClusterName, func(t *testing.T) { + // given + SetFileConfig(t, WithValues(clusterConfigParam, ClusterType(""))) + term := NewFakeTerminal() + + // when + cfg, err := configuration.LoadClusterConfig(term, clusterName) + + // then + require.EqualError(t, err, "sandbox command failed: 'cluster type' is not set for cluster '"+clusterConfigParam.ClusterName+"'") + assert.Empty(t, cfg.ClusterType) + }) + } + + for _, clusterConfigParam := range []ClusterDefinitionWithName{Host(NoToken()), Member(NoToken())} { + clusterConfigParam.ClusterName = testCase.transform(clusterConfigParam.ClusterName) + clusterName := clusterConfigParam.ClusterName + + t.Run("when token is not set for "+clusterName, func(t *testing.T) { + // given + SetFileConfig(t, clusterConfigParam) + term := NewFakeTerminal() + + // when + cfg, err := configuration.LoadClusterConfig(term, clusterName) + + // then + require.EqualError(t, err, "sandbox command failed: the token in your sandbox.yaml file is missing") + assert.Empty(t, cfg.Token) + }) + } + + for _, clusterConfigParam := range []ClusterDefinitionWithName{Host(), Member()} { + clusterConfigParam.ClusterName = testCase.transform(clusterConfigParam.ClusterName) + clusterName := clusterConfigParam.ClusterName + + t.Run("when server api is not set for "+clusterName, func(t *testing.T) { + // given + SetFileConfig(t, WithValues(clusterConfigParam, ServerAPI(""))) + term := NewFakeTerminal() + + // when + cfg, err := configuration.LoadClusterConfig(term, clusterName) + + // then + require.EqualError(t, err, "sandbox command failed: The server API is not set for the cluster "+clusterName) + assert.Empty(t, cfg.ServerAPI) + }) + } + + for _, clusterConfigParam := range []ClusterDefinitionWithName{Host(), Member()} { + clusterConfigParam.ClusterName = testCase.transform(clusterConfigParam.ClusterName) + clusterName := clusterConfigParam.ClusterName + + t.Run("when server name is not set for "+clusterName, func(t *testing.T) { + // given + SetFileConfig(t, WithValues(clusterConfigParam, ServerName(""))) + term := NewFakeTerminal() + + // when + cfg, err := configuration.LoadClusterConfig(term, clusterName) + + // then + require.EqualError(t, err, "sandbox command failed: The server name is not set for the cluster "+clusterName) + assert.Empty(t, cfg.ServerName) + }) + } + + t.Run("when no cluster name is defined", func(t *testing.T) { + // given + SetFileConfig(t) + term := NewFakeTerminal() + + // when + _, err := configuration.LoadClusterConfig(term, "dummy") + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "the provided cluster-name 'dummy' is not present in your sandbox.yaml file. The available cluster names are") + }) + + for _, clusterConfigParam := range []ClusterDefinitionWithName{Host(), Member()} { + clusterConfigParam.ClusterName = testCase.transform(clusterConfigParam.ClusterName) + clusterName := clusterConfigParam.ClusterName + + t.Run("when multiple cluster names are defined", func(t *testing.T) { + // given + SetFileConfig(t, Host(), Member(), Member(ClusterName("member2"))) + term := NewFakeTerminal() + + // when + cfg, err := configuration.LoadClusterConfig(term, clusterName) + + // then + require.NoError(t, err) + assert.Len(t, cfg.AllClusterNames, 3) + assert.Contains(t, cfg.AllClusterNames, "host") + assert.Contains(t, cfg.AllClusterNames, "member-1") + assert.Contains(t, cfg.AllClusterNames, "member-2") + }) + } + }) + } +} + +func TestLoadingClusterConfigWithNonexistentClusterName(t *testing.T) { + // given + SetFileConfig(t, Host(), Member()) + term := NewFakeTerminal() + + // when + cfg, err := configuration.LoadClusterConfig(term, "dummy") + + // then + require.Error(t, err) + assert.Contains(t, err.Error(), "the provided cluster-name 'dummy' is not present in your sandbox.yaml file. The available cluster names are") + assert.Contains(t, err.Error(), "host") + assert.Contains(t, err.Error(), "member-1") + assert.Empty(t, cfg.SandboxNamespace) +} + +func TestLoad(t *testing.T) { + + t.Run("with verbose messages", func(t *testing.T) { + // given + term := NewFakeTerminal() + SetFileConfig(t, Host(), Member()) + configuration.Verbose = true + + // when + sandboxUserConfig, path, err := configuration.Load(term) + + // then + require.NoError(t, err) + expectedConfig := NewSandboxUserConfig(Host(), Member()) + assert.Equal(t, expectedConfig, sandboxUserConfig) + assert.Contains(t, term.Output(), "Using config file") + assert.True(t, strings.HasPrefix(path, os.TempDir())) + + t.Run("reload again the same", func(t *testing.T) { + // given + term := NewFakeTerminal() + + // when + sandboxUserConfig, theSamePath, err := configuration.Load(term) + + // then + require.NoError(t, err) + assert.Equal(t, expectedConfig, sandboxUserConfig) + assert.Contains(t, term.Output(), "Using config file") + assert.True(t, strings.HasPrefix(path, os.TempDir())) + assert.Equal(t, path, theSamePath) + }) + + t.Run("load a new one", func(t *testing.T) { + // given + term := NewFakeTerminal() + SetFileConfig(t, Host(), Member()) + + // when + sandboxUserConfig, newPath, err := configuration.Load(term) + + // then + require.NoError(t, err) + expectedConfig := NewSandboxUserConfig(Host(), Member()) + assert.Equal(t, expectedConfig, sandboxUserConfig) + assert.Contains(t, term.Output(), "Using config file") + assert.True(t, strings.HasPrefix(path, os.TempDir())) + assert.NotEqual(t, path, newPath) + }) + }) + + t.Run("without verbose messages", func(t *testing.T) { + // given + term := NewFakeTerminal() + SetFileConfig(t, Host(), Member()) + configuration.Verbose = false + + // when + sandboxUserConfig, path, err := configuration.Load(term) + + // then + require.NoError(t, err) + expectedConfig := NewSandboxUserConfig(Host(), Member()) + assert.Equal(t, expectedConfig, sandboxUserConfig) + assert.NotContains(t, term.Output(), "Using config file") + assert.True(t, strings.HasPrefix(path, os.TempDir())) + }) + +} + +func TestLoadFails(t *testing.T) { + t.Run("file does not exist", func(t *testing.T) { + // given + term := NewFakeTerminal() + configuration.ConfigFileFlag = "/tmp/should-not-exist.yaml" + + // when + _, _, err := configuration.Load(term) + + // then + require.Error(t, err) + }) + + t.Run("file is directory", func(t *testing.T) { + // given + term := NewFakeTerminal() + configuration.ConfigFileFlag = os.TempDir() + + // when + _, _, err := configuration.Load(term) + + // then + require.Error(t, err) + }) +} + +func TestAsSuffix(t *testing.T) { + t.Run("host type", func(t *testing.T) { + // given + prefix := "prefix" + + // when + result := configuration.Host.AsSuffix(prefix) + + // then + assert.Equal(t, "prefix-host", result) + }) + + t.Run("member type", func(t *testing.T) { + // given + prefix := "prefix" + + // when + result := configuration.Member.AsSuffix(prefix) + + // then + assert.Equal(t, "prefix-member", result) + }) +} diff --git a/pkg/context/clusterconfig_command_context.go b/pkg/context/clusterconfig_command_context.go new file mode 100644 index 0000000..aa1a1cc --- /dev/null +++ b/pkg/context/clusterconfig_command_context.go @@ -0,0 +1,37 @@ +package context + +import ( + "path/filepath" + + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/configuration" + "github.com/kubesaw/ksctl/pkg/ioutils" +) + +// ClusterConfigCommandContext the context of the admin command to run +type ClusterConfigCommandContext struct { + CommandContext + Files assets.FS + ClusterConfig configuration.ClusterConfig + ClusterConfigName string +} + +// NewClusterConfigCommandContext returns the context of the admin command to run +func NewClusterConfigCommandContext(term ioutils.Terminal, cfg configuration.ClusterConfig, newClient NewClientFunc, newRESTClient NewRESTClientFunc, files assets.FS, clusterConfigName string) *ClusterConfigCommandContext { + return &ClusterConfigCommandContext{ + CommandContext: CommandContext{ + Terminal: term, + NewClient: newClient, + NewRESTClient: newRESTClient, + }, + Files: files, + ClusterConfig: cfg, + ClusterConfigName: clusterConfigName, + } +} + +func (ctx *ClusterConfigCommandContext) GetFileContent(path ...string) ([]byte, error) { + p := filepath.Join(path...) + return ctx.Files.ReadFile(p) + +} diff --git a/pkg/context/clusterconfig_command_context_test.go b/pkg/context/clusterconfig_command_context_test.go new file mode 100644 index 0000000..448a0a6 --- /dev/null +++ b/pkg/context/clusterconfig_command_context_test.go @@ -0,0 +1,57 @@ +package context_test + +import ( + "testing" + + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + "github.com/kubesaw/ksctl/resources" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigPath(t *testing.T) { + + // given + clusterConfigParams := []ClusterDefinitionWithName{ + Host(ServerName("host-cluster")), + Member(ServerName("member-cluster"))} + SetFileConfig(t, clusterConfigParams...) + + for _, clusterConfigParam := range clusterConfigParams { + + term := NewFakeTerminal() + clusterName := clusterConfigParam.ClusterName + cfg, err := configuration.LoadClusterConfig(term, clusterName) + require.NoError(t, err) + + t.Run(string(cfg.ClusterType), func(t *testing.T) { + + t.Run("with explicit clusterName", func(t *testing.T) { + // given + ctx := clicontext.NewClusterConfigCommandContext(term, cfg, nil, nil, resources.Resources, "custom_path") + + // when + path, err := cfg.ConfigurePath(ctx, ctx.ClusterConfigName, "component") + + // then + require.NoError(t, err) + assert.Equal(t, "custom_path/configure/"+cfg.ClusterType.String()+"/component", path) + }) + + t.Run("without explicit clusterName", func(t *testing.T) { + // given + ctx := clicontext.NewClusterConfigCommandContext(term, cfg, nil, nil, resources.Resources, "") // default path + + // when + path, err := cfg.ConfigurePath(ctx, ctx.ClusterConfigName, "component") + + // then + require.NoError(t, err) + assert.Equal(t, "host-cluster/configure/"+cfg.ClusterType.String()+"/component", path) // no matter if the cluster is the Host or a Member + }) + }) + } +} diff --git a/pkg/context/command_context.go b/pkg/context/command_context.go new file mode 100644 index 0000000..c1dde15 --- /dev/null +++ b/pkg/context/command_context.go @@ -0,0 +1,32 @@ +package context + +import ( + "context" + "github.com/kubesaw/ksctl/pkg/ioutils" + + "k8s.io/client-go/rest" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// CommandContext the context of the (standard) command to run +type CommandContext struct { + context.Context + ioutils.Terminal + NewClient NewClientFunc + NewRESTClient NewRESTClientFunc +} + +// NewClientFunc a function to create a `client.Client` with the given token and API endpoint +type NewClientFunc func(string, string) (runtimeclient.Client, error) + +type NewRESTClientFunc func(token, apiEndpoint string) (*rest.RESTClient, error) + +// NewCommandContext returns the context of the command to run +func NewCommandContext(term ioutils.Terminal, newClient NewClientFunc, newRESTClient NewRESTClientFunc) *CommandContext { + return &CommandContext{ + Context: context.Background(), + Terminal: term, + NewClient: newClient, + NewRESTClient: newRESTClient, + } +} diff --git a/pkg/context/command_context_test.go b/pkg/context/command_context_test.go new file mode 100644 index 0000000..36fda81 --- /dev/null +++ b/pkg/context/command_context_test.go @@ -0,0 +1,42 @@ +package context_test + +import ( + "testing" + + "github.com/kubesaw/ksctl/pkg/configuration" + clicontext "github.com/kubesaw/ksctl/pkg/context" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/require" +) + +func TestLoadClusterConfig(t *testing.T) { + // given + SetFileConfig(t, Host()) + + t.Run("success", func(t *testing.T) { + newClient, newRESTClient, _ := NewFakeClients(t) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + _, err := configuration.LoadClusterConfig(ctx, "host") + + // then + require.NoError(t, err) + }) + + t.Run("fail", func(t *testing.T) { + // given + SetFileConfig(t, Host(NoToken()), Member(NoToken())) + newClient, newRESTClient, _ := NewFakeClients(t) + term := NewFakeTerminalWithResponse("Y") + ctx := clicontext.NewCommandContext(term, newClient, newRESTClient) + + // when + _, err := configuration.LoadClusterConfig(ctx, "host") + + // then + require.Error(t, err) + }) +} diff --git a/pkg/ioutils/terminal.go b/pkg/ioutils/terminal.go new file mode 100644 index 0000000..2fcfb52 --- /dev/null +++ b/pkg/ioutils/terminal.go @@ -0,0 +1,155 @@ +package ioutils + +import ( + "bufio" + "fmt" + "io" + "log" + "strings" + + "github.com/ghodss/yaml" + errs "github.com/pkg/errors" + "golang.org/x/term" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" +) + +// AssumeYes automatically answers yes for all questions. +var AssumeYes bool + +// Terminal a wrapper around a Cobra command, with extra methods +// to display messages. +type Terminal interface { + InOrStdin() io.Reader + OutOrStdout() io.Writer + AskForConfirmation(msg ConfirmationMessage) bool + Println(msg string) + Printlnf(msg string, args ...interface{}) + PrintContextSeparatorf(context string, args ...interface{}) + PrintContextSeparatorWithBodyf(body, context string, args ...interface{}) + PrintObject(object runtime.Object, title string) error +} + +// NewTerminal returns a new terminal with the given funcs to +// access the `in` reader and `out` writer +func NewTerminal(in func() io.Reader, out func() io.Writer) Terminal { + return &DefaultTerminal{ + in: in, + out: out, + } +} + +// DefaultTerminal a wrapper around a Cobra command, with extra methods +// to display messages. +type DefaultTerminal struct { + in func() io.Reader + out func() io.Writer +} + +// InOrStdin returns an `io.Reader` to read the user's input +func (t *DefaultTerminal) InOrStdin() io.Reader { + return t.in() +} + +// OutOrStdout returns an `io.Writer` to write messages in the console +func (t *DefaultTerminal) OutOrStdout() io.Writer { + return t.out() +} + +// Println prints the given message and appends a line feed +func (t *DefaultTerminal) Println(msg string) { + fmt.Fprintln(t.OutOrStdout(), msg) +} + +// Printf prints the given message with arguments +func (t *DefaultTerminal) Printf(format string, args ...interface{}) { + fmt.Fprintf(t.OutOrStdout(), format, args...) +} + +// Printlnf prints the given message with arguments and appends a line feed +func (t *DefaultTerminal) Printlnf(format string, args ...interface{}) { + fmt.Fprintf(t.OutOrStdout(), format+"\n", args...) +} + +// PrintContextSeparatorf prints the context separator (only) +func (t *DefaultTerminal) PrintContextSeparatorf(context string, args ...interface{}) { + t.PrintContextSeparatorWithBodyf("", context, args...) +} + +// PrintContextSeparatorWithBodyf prints the context separator and a message +func (t *DefaultTerminal) PrintContextSeparatorWithBodyf(body, context string, args ...interface{}) { + width, _, err := term.GetSize(0) + if err != nil { + width = 60 + } + line := strings.Repeat("-", width) + fmt.Fprintln(t.OutOrStdout(), "\n"+line) + fmt.Fprintln(t.OutOrStdout(), " "+fmt.Sprintf(context, args...)) + if body != "" { + fmt.Fprintln(t.OutOrStdout(), line) + fmt.Fprintln(t.OutOrStdout(), body) + } + fmt.Fprintln(t.OutOrStdout(), line) +} + +// PrintObject prints the given object +func (t *DefaultTerminal) PrintObject(object runtime.Object, title string) error { + toPrint := object.DeepCopyObject() + toPrintMeta, err := meta.Accessor(toPrint) + if err != nil { + return errs.Wrapf(err, "cannot get metadata from %+v", object) + } + toPrintMeta.SetManagedFields(nil) + result, err := yaml.Marshal(toPrint) + if err != nil { + return errs.Wrapf(err, "unable to unmarshal %+v", object) + } + t.PrintContextSeparatorWithBodyf(string(result), title) + return nil +} + +func WithDangerZoneMessagef(consequence, action string, args ...interface{}) ConfirmationMessage { + return ConfirmationMessage(fmt.Sprintf(` +################################### +#### #### +#### !!! DANGER ZONE !!! #### +#### #### +################################### + +THIS COMMAND WILL CAUSE %s +%s`, strings.ToUpper(consequence), WithMessagef(action, args...))) +} + +func WithMessagef(action string, args ...interface{}) ConfirmationMessage { + return ConfirmationMessage(fmt.Sprintf(` +Are you sure that you want to %s`, fmt.Sprintf(action, args...))) +} + +type ConfirmationMessage string + +func (t *DefaultTerminal) AskForConfirmation(msg ConfirmationMessage) bool { + reader := bufio.NewReader(t.InOrStdin()) + t.Printlnf(string(msg)) + t.Printlnf("===============================") + t.Printf("[y/n] -> ") + text := "" + var err error + if AssumeYes { + text = "y" + } else { + text, err = reader.ReadString('\n') + if err != nil { + log.Fatal("unable to read from input: ", err) + } + } + text = strings.ReplaceAll(text, "\n", "") + t.Printlnf("response: '%s'", text) + switch text { + case "y", "Y": + return true + case "n", "N": + return false + default: + return t.AskForConfirmation("answer y or n") + } +} diff --git a/pkg/ioutils/terminal_test.go b/pkg/ioutils/terminal_test.go new file mode 100644 index 0000000..78da621 --- /dev/null +++ b/pkg/ioutils/terminal_test.go @@ -0,0 +1,109 @@ +package ioutils_test + +import ( + "bytes" + "io" + "testing" + + "github.com/kubesaw/ksctl/pkg/ioutils" + . "github.com/kubesaw/ksctl/pkg/test" + + "github.com/stretchr/testify/assert" +) + +func TestAskForConfirmationWhenAnswerIsY(t *testing.T) { + for _, answer := range []string{"y", "Y"} { + // given + term := NewFakeTerminalWithResponse(answer) + + // when + confirmation := term.AskForConfirmation(ioutils.WithMessagef("do some %s", "action")) + + // then + assert.True(t, confirmation) + output := term.Output() + assert.Contains(t, output, "Are you sure that you want to do some action\n===============================\n[y/n] -> ") + assert.NotContains(t, output, "!!! DANGER ZONE !!!") + } +} + +func TestAskForConfirmationWhenAssumeYesIsTrue(t *testing.T) { + // given + term := NewFakeTerminalWithResponse("n") + ioutils.AssumeYes = true + t.Cleanup(func() { + ioutils.AssumeYes = false + }) + + // when + confirmation := term.AskForConfirmation(ioutils.WithMessagef("do some %s", "action")) + + // then + assert.True(t, confirmation) + output := term.Output() + assert.Contains(t, output, "Are you sure that you want to do some action\n===============================\n[y/n] -> ") + assert.Contains(t, output, "[y/n] -> response: 'y'") + assert.NotContains(t, output, "!!! DANGER ZONE !!!") +} + +func TestAskForConfirmationWhenAnswerIsNWithDangerZone(t *testing.T) { + for _, answer := range []string{"n", "N"} { + // given + term := NewFakeTerminalWithResponse(answer) + + // when + confirmation := term.AskForConfirmation(ioutils.WithDangerZoneMessagef("a consequence", "do some %s", "action")) + + // then + assert.False(t, confirmation) + output := term.Output() + assert.Contains(t, output, "!!! DANGER ZONE !!!") + assert.Contains(t, output, "THIS COMMAND WILL CAUSE A CONSEQUENCE") + assert.Contains(t, output, "Are you sure that you want to do some action\n===============================\n[y/n] -> ") + } +} + +func TestAskForConfirmationWhenFirstAnswerIsWrong(t *testing.T) { + // given + createTerm := func(correctAnswer string) ioutils.Terminal { + counter := 0 + return ioutils.NewTerminal( + func() io.Reader { + in := bytes.NewBuffer(nil) + if counter == 0 { + in.WriteString("bla") + counter++ + } else { + in.WriteString(correctAnswer) + } + in.WriteByte('\n') + return in + }, + func() io.Writer { + return bytes.NewBuffer(nil) + }, + ) + } + + t.Run("second answer is y", func(t *testing.T) { + // given + term := createTerm("y") + + // when + confirmation := term.AskForConfirmation(ioutils.WithMessagef("do some %s", "action")) + + // then + assert.True(t, confirmation) + }) + + t.Run("second answer is n", func(t *testing.T) { + // given + term := createTerm("n") + + // when + confirmation := term.AskForConfirmation(ioutils.WithMessagef("do some %s", "action")) + + // then + assert.False(t, confirmation) + }) +} diff --git a/pkg/test/banneduser.go b/pkg/test/banneduser.go new file mode 100644 index 0000000..3e66a04 --- /dev/null +++ b/pkg/test/banneduser.go @@ -0,0 +1,30 @@ +package test + +import ( + "context" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func AssertBannedUser(t *testing.T, fakeClient *test.FakeClient, userSignup *toolchainv1alpha1.UserSignup) { + bannedUsers := &toolchainv1alpha1.BannedUserList{} + err := fakeClient.List(context.TODO(), bannedUsers, runtimeclient.InNamespace(userSignup.Namespace)) + require.NoError(t, err) + require.Len(t, bannedUsers.Items, 1) + assert.Equal(t, userSignup.Spec.IdentityClaims.Email, bannedUsers.Items[0].Spec.Email) + assert.Equal(t, userSignup.Labels[toolchainv1alpha1.UserSignupUserEmailHashLabelKey], bannedUsers.Items[0].Labels[toolchainv1alpha1.BannedUserEmailHashLabelKey]) + assert.Equal(t, userSignup.Labels[toolchainv1alpha1.UserSignupUserPhoneHashLabelKey], bannedUsers.Items[0].Labels[toolchainv1alpha1.BannedUserPhoneNumberHashLabelKey]) +} + +func AssertNoBannedUser(t *testing.T, fakeClient *test.FakeClient, userSignup *toolchainv1alpha1.UserSignup) { + bannedUsers := &toolchainv1alpha1.BannedUserList{} + err := fakeClient.List(context.TODO(), bannedUsers, runtimeclient.InNamespace(userSignup.Namespace)) + require.NoError(t, err) + require.Empty(t, bannedUsers.Items) +} diff --git a/pkg/test/client.go b/pkg/test/client.go new file mode 100644 index 0000000..6b4e4ff --- /dev/null +++ b/pkg/test/client.go @@ -0,0 +1,93 @@ +package test + +import ( + "context" + "os/exec" + "testing" + + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/kubesaw/ksctl/pkg/client" + clicontext "github.com/kubesaw/ksctl/pkg/context" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewFakeClients(t *testing.T, initObjs ...runtime.Object) (clicontext.NewClientFunc, clicontext.NewRESTClientFunc, *test.FakeClient) { + fakeClient := test.NewFakeClient(t, initObjs...) + fakeClient.MockCreate = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.CreateOption) error { + stringDataToData(obj) + return fakeClient.Client.Create(ctx, obj, opts...) + } + fakeClient.MockUpdate = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.UpdateOption) error { + stringDataToData(obj) + return fakeClient.Client.Update(ctx, obj, opts...) + } + return func(token, apiEndpoint string) (runtimeclient.Client, error) { + assert.Equal(t, "cool-token", token) + assert.Contains(t, apiEndpoint, "http") + assert.Contains(t, apiEndpoint, "://") + assert.Contains(t, apiEndpoint, ".com") + return fakeClient, nil + }, + func(token string, apiEndpoint string) (*rest.RESTClient, error) { + return NewFakeExternalClient(t, token, apiEndpoint), nil + }, + fakeClient +} + +func NewFakeExternalClient(t *testing.T, token string, apiEndpoint string) *rest.RESTClient { + // mock request to download a script from GitHub + gock.New("https://raw.githubusercontent.com"). + Get("codeready-toolchain/toolchain-cicd/master/scripts/add-cluster.sh"). + Persist(). // make sure multiple requests are all handled by Gock + Reply(200) + + // gock.Observe(gock.DumpRequest) + t.Cleanup(gock.OffAll) + cl, err := client.NewRESTClient(token, apiEndpoint) + require.NoError(t, err) + // override the underlying client's transport with Gock to intercep requests + cl.Client.Transport = gock.DefaultTransport + return cl +} + +func stringDataToData(obj runtimeclient.Object) { + if obj.GetObjectKind().GroupVersionKind().Kind == "Secret" { + secret := obj.(*corev1.Secret) + if secret.Data == nil { + secret.Data = map[string][]byte{} + } + for key, value := range secret.StringData { + secret.Data[key] = []byte(value) + } + } +} + +func AssertArgsEqual(expArgs ...string) ArgsAssertion { + return func(t *testing.T, actualArgs ...string) { + assert.Equal(t, expArgs, actualArgs) + } +} + +func AssertFirstArgPrefixRestEqual(firstArgPrefix string, toEqual ...string) ArgsAssertion { + return func(t *testing.T, actualArgs ...string) { + assert.Regexp(t, firstArgPrefix, actualArgs[0]) + assert.Equal(t, toEqual, actualArgs[1:]) + } +} + +type ArgsAssertion func(*testing.T, ...string) + +func NewCommandCreator(t *testing.T, cmd string, expCmd string, assertArgs ArgsAssertion) client.CommandCreator { + return func(name string, actualArgs ...string) *exec.Cmd { + assert.Equal(t, expCmd, name) + assertArgs(t, actualArgs...) + return exec.Command(cmd, name) + } +} diff --git a/pkg/test/config.go b/pkg/test/config.go new file mode 100644 index 0000000..845854c --- /dev/null +++ b/pkg/test/config.go @@ -0,0 +1,127 @@ +package test + +import ( + "os" + "testing" + + "github.com/kubesaw/ksctl/pkg/configuration" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +type ClusterDefinitionWithName struct { + configuration.ClusterAccessDefinition + ClusterName string +} + +// ConfigOption an option on the configuration generated for a test +type ConfigOption func(*ClusterDefinitionWithName) + +// NoToken deletes the default token set for the cluster +func NoToken() ConfigOption { + return func(content *ClusterDefinitionWithName) { + content.Token = "" + } +} + +// ServerAPI specifies the ServerAPI to use (default is `https://cool-server.com`) +func ServerAPI(serverAPI string) ConfigOption { + return func(content *ClusterDefinitionWithName) { + content.ServerAPI = serverAPI + } +} + +// ClusterName specifies the name of the server (default is `host` or `member1`) +func ClusterName(clusterName string) ConfigOption { + return func(content *ClusterDefinitionWithName) { + content.ClusterName = clusterName + } +} + +// ServerName specifies the name of the server (default is `cool-server.com`) +func ServerName(serverName string) ConfigOption { + return func(content *ClusterDefinitionWithName) { + content.ServerName = serverName + } +} + +// ClusterType specifies the cluster type (`host` or `member`) +func ClusterType(clusterType string) ConfigOption { + return func(content *ClusterDefinitionWithName) { + content.ClusterType = configuration.ClusterType(clusterType) + } +} + +// Host defines the configuration for the host cluster +func Host(options ...ConfigOption) ClusterDefinitionWithName { + clusterDef := ClusterDefinitionWithName{ + ClusterName: "host", + ClusterAccessDefinition: configuration.ClusterAccessDefinition{ + ClusterDefinition: configuration.ClusterDefinition{ + ServerAPI: "https://cool-server.com", + ServerName: "cool-server.com", + ClusterType: configuration.Host, + }, + Token: "cool-token", + }, + } + return WithValues(clusterDef, options...) +} + +// Member defines the configuration for a member cluster +func Member(options ...ConfigOption) ClusterDefinitionWithName { + clusterDef := ClusterDefinitionWithName{ + ClusterName: "member1", + ClusterAccessDefinition: configuration.ClusterAccessDefinition{ + ClusterDefinition: configuration.ClusterDefinition{ + ServerAPI: "https://cool-server.com", + ServerName: "cool-server.com", + ClusterType: configuration.Member, + }, + Token: "cool-token", + }, + } + return WithValues(clusterDef, options...) +} + +// WithValues applies the options on the given parameters +func WithValues(clusterDef ClusterDefinitionWithName, options ...ConfigOption) ClusterDefinitionWithName { + for _, modify := range options { + modify(&clusterDef) + } + return clusterDef +} + +// NewSandboxUserConfig creates SandboxUserConfig object with the given cluster definitions +func NewSandboxUserConfig(clusterDefs ...ClusterDefinitionWithName) configuration.SandboxUserConfig { + sandboxUserConfig := configuration.SandboxUserConfig{ + Name: "john", + ClusterAccessDefinitions: map[string]configuration.ClusterAccessDefinition{}, + } + for _, clusterDefWithName := range clusterDefs { + sandboxUserConfig.ClusterAccessDefinitions[clusterDefWithName.ClusterName] = clusterDefWithName.ClusterAccessDefinition + } + return sandboxUserConfig +} + +// SetFileConfig generates the configuration file to use during a test +// The file is automatically cleanup at the end of the test. +func SetFileConfig(t *testing.T, clusterDefs ...ClusterDefinitionWithName) { + tmpFile, err := os.CreateTemp(os.TempDir(), "configFile-*.yaml") + require.NoError(t, err) + fileName := tmpFile.Name() + t.Cleanup(func() { + err := os.Remove(fileName) + require.NoError(t, err) + configuration.ConfigFileFlag = "" + }) + + sandboxUserConfig := NewSandboxUserConfig(clusterDefs...) + out, err := yaml.Marshal(sandboxUserConfig) + require.NoError(t, err) + err = os.WriteFile(fileName, out, 0600) + require.NoError(t, err) + require.NoError(t, tmpFile.Close()) + configuration.ConfigFileFlag = fileName + t.Logf("config file: %s: \n%s", fileName, string(out)) +} diff --git a/pkg/test/environment_config.go b/pkg/test/environment_config.go new file mode 100644 index 0000000..b56525c --- /dev/null +++ b/pkg/test/environment_config.go @@ -0,0 +1,152 @@ +package test + +import ( + "github.com/kubesaw/ksctl/pkg/assets" + "github.com/kubesaw/ksctl/pkg/configuration" +) + +func NewSandboxEnvironmentConfig(addClusters ClustersCreator, serviceAccounts []assets.ServiceAccount, users []assets.User) *assets.SandboxEnvironmentConfig { + sc := &assets.SandboxEnvironmentConfig{ + ServiceAccounts: serviceAccounts, + Users: users, + } + addClusters(&sc.Clusters) + return sc +} + +type ClustersCreator func(*assets.Clusters) + +func Clusters(hostURL string) ClustersCreator { + return func(clusters *assets.Clusters) { + clusters.Host = assets.ClusterConfig{ + API: hostURL, + } + } +} + +func (m ClustersCreator) AddMember(name, URL string) ClustersCreator { + return func(clusters *assets.Clusters) { + m(clusters) + clusters.Members = append(clusters.Members, assets.MemberCluster{ + Name: name, + ClusterConfig: assets.ClusterConfig{ + API: URL, + }, + }) + } +} + +type ServiceAccountCreator func() assets.ServiceAccount + +func ServiceAccounts(serviceAccountModifiers ...ServiceAccountCreator) []assets.ServiceAccount { + var serviceAccounts []assets.ServiceAccount + for _, createSa := range serviceAccountModifiers { + serviceAccounts = append(serviceAccounts, createSa()) + } + return serviceAccounts +} + +func Sa(baseName, namespace string, permissions ...PermissionsPerClusterTypeModifier) ServiceAccountCreator { //nolint:unparam + return func() assets.ServiceAccount { + sa := assets.ServiceAccount{ + Name: baseName, + Namespace: namespace, + PermissionsPerClusterType: NewPermissionsPerClusterType(permissions...), + } + return sa + } +} + +func NewPermissionsPerClusterType(permissions ...PermissionsPerClusterTypeModifier) assets.PermissionsPerClusterType { + perm := map[string]assets.PermissionBindings{} + for _, addPermissions := range permissions { + addPermissions(perm) + } + return perm +} + +type RoleBindingsModifier func(*assets.RoleBindings) +type PermissionsPerClusterTypeModifier func(assets.PermissionsPerClusterType) + +func HostRoleBindings(namespace string, modifiers ...RoleBindingsModifier) PermissionsPerClusterTypeModifier { + return func(namespacePermissionsPerClusterType assets.PermissionsPerClusterType) { + RoleBindings(namespacePermissionsPerClusterType, configuration.Host, namespace, modifiers...) + } +} + +func MemberRoleBindings(namespace string, modifiers ...RoleBindingsModifier) PermissionsPerClusterTypeModifier { + return func(namespacePermissionsPerClusterType assets.PermissionsPerClusterType) { + RoleBindings(namespacePermissionsPerClusterType, configuration.Member, namespace, modifiers...) + } +} + +func RoleBindings(namespacePermissionsPerClusterType assets.PermissionsPerClusterType, clusterType configuration.ClusterType, namespace string, modifiers ...RoleBindingsModifier) { + nsPermissions := assets.RoleBindings{ + Namespace: namespace, + } + for _, modify := range modifiers { + modify(&nsPermissions) + } + permissions := namespacePermissionsPerClusterType[clusterType.String()] + permissions.RoleBindings = append(permissions.RoleBindings, nsPermissions) + namespacePermissionsPerClusterType[clusterType.String()] = permissions +} + +func HostClusterRoleBindings(clusterRoles ...string) PermissionsPerClusterTypeModifier { + return func(namespacePermissionsPerClusterType assets.PermissionsPerClusterType) { + ClusterRolesBindings(namespacePermissionsPerClusterType, configuration.Host, clusterRoles...) + } +} + +func MemberClusterRoleBindings(clusterRoles ...string) PermissionsPerClusterTypeModifier { + return func(namespacePermissionsPerClusterType assets.PermissionsPerClusterType) { + ClusterRolesBindings(namespacePermissionsPerClusterType, configuration.Member, clusterRoles...) + } +} + +func ClusterRolesBindings(namespacePermissionsPerClusterType assets.PermissionsPerClusterType, clusterType configuration.ClusterType, clusterRoles ...string) { + roles := namespacePermissionsPerClusterType[clusterType.String()] + roles.ClusterRoleBindings.ClusterRoles = append(roles.ClusterRoleBindings.ClusterRoles, clusterRoles...) + namespacePermissionsPerClusterType[clusterType.String()] = roles +} + +func Role(roles ...string) RoleBindingsModifier { + return func(roleBinding *assets.RoleBindings) { + roleBinding.Roles = append(roleBinding.Roles, roles...) + } +} + +func ClusterRole(clusterRoles ...string) RoleBindingsModifier { + return func(roleBinding *assets.RoleBindings) { + roleBinding.ClusterRoles = append(roleBinding.ClusterRoles, clusterRoles...) + } +} + +type UserCreator func() assets.User + +func Users(userCreators ...UserCreator) []assets.User { + var users []assets.User + for _, createUser := range userCreators { + users = append(users, createUser()) + } + return users +} + +func User(name string, IDs []string, group string, permissions ...PermissionsPerClusterTypeModifier) UserCreator { + return func() assets.User { + var groups []string + if group != "" { + groups = []string{group} + } + user := assets.User{ + Name: name, + ID: IDs, + Groups: groups, + PermissionsPerClusterType: map[string]assets.PermissionBindings{}, + } + for _, addPermissions := range permissions { + addPermissions(user.PermissionsPerClusterType) + } + return user + } +} diff --git a/pkg/test/fake_files.go b/pkg/test/fake_files.go new file mode 100644 index 0000000..6301daa --- /dev/null +++ b/pkg/test/fake_files.go @@ -0,0 +1,139 @@ +package test + +import ( + "fmt" + "io/fs" + "path/filepath" + "testing" + + "github.com/ghodss/yaml" + "github.com/kubesaw/ksctl/pkg/assets" + v1 "github.com/openshift/api/template/v1" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" +) + +type FakeFileCreator func(t *testing.T) (string, []byte) + +func FakeFile(path string, content []byte) FakeFileCreator { + return func(t *testing.T) (string, []byte) { + return path, content + } +} + +func FakeTemplate(path string, objects ...runtime.Object) FakeFileCreator { + return FakeTemplateWithParams(path, []string{}, objects...) +} + +func FakeTemplateWithParams(path string, requiredParams []string, objects ...runtime.Object) FakeFileCreator { + return func(t *testing.T) (s string, bytes []byte) { + tmpl := v1.Template{} + tmpl.Name = "fake-template" + for _, object := range objects { + tmpl.Objects = append(tmpl.Objects, runtime.RawExtension{ + Object: object, + }) + } + + for _, param := range requiredParams { + tmpl.Parameters = append(tmpl.Parameters, v1.Parameter{ + Name: param, + Required: true, + }) + } + + content, err := yaml.Marshal(tmpl) + require.NoError(t, err) + return path, content + } +} + +func NewFakeFiles(t *testing.T, fakeFiles ...FakeFileCreator) assets.FS { + files := map[string][]byte{} + for _, getFile := range fakeFiles { + path, bytes := getFile(t) + files[path] = bytes + } + return &fakeFS{ + files: files, + } +} + +type fakeFS struct { + files map[string][]byte +} + +var _ assets.FS = &fakeFS{} + +func (f *fakeFS) Open(name string) (fs.File, error) { + if content, found := f.files[name]; found { + return &fakeFile{ + content: content, + }, nil + } + return nil, fmt.Errorf("file not found: %s", name) +} + +func (f *fakeFS) ReadDir(name string) ([]fs.DirEntry, error) { + result := []fs.DirEntry{} + for n := range f.files { + if filepath.Dir(n) == name { + result = append(result, &fakeDirEntry{ + name: filepath.Base(n), + }) + } + } + + return result, nil +} + +func (f *fakeFS) ReadFile(name string) ([]byte, error) { + if content, found := f.files[name]; found { + return content, nil + } + return nil, fmt.Errorf("file not found: %s", name) +} + +type fakeFile struct { + content []byte +} + +var _ fs.File = &fakeFile{} + +func (f *fakeFile) Read(out []byte) (int, error) { + copy(out, f.content) + return len(f.content), nil +} + +func (f *fakeFile) Stat() (fs.FileInfo, error) { + // not implemented + return nil, nil +} + +func (f *fakeFile) Close() error { + // not implemened + return nil +} + +type fakeDirEntry struct { + name string +} + +var _ fs.DirEntry = &fakeDirEntry{} + +func (f *fakeDirEntry) Name() string { + return f.name +} + +func (f *fakeDirEntry) IsDir() bool { + return false +} + +func (f *fakeDirEntry) Type() fs.FileMode { + return fs.ModePerm +} + +func (f *fakeDirEntry) Info() (fs.FileInfo, error) { + // not implemented + return nil, nil +} diff --git a/pkg/test/fake_terminal.go b/pkg/test/fake_terminal.go new file mode 100644 index 0000000..fe3422d --- /dev/null +++ b/pkg/test/fake_terminal.go @@ -0,0 +1,65 @@ +package test + +import ( + "bytes" + "io" + + "github.com/kubesaw/ksctl/pkg/ioutils" +) + +// FakeTerminal a fake terminal, which can: +// - be configured to always return the same response when the command prompts the user for confirmation +// - capture the command output +type FakeTerminal struct { + out *bytes.Buffer + ioutils.Terminal +} + +// NewFakeTerminal returns a new FakeTerminal which will +// only print messages in the console +func NewFakeTerminal() *FakeTerminal { + out := bytes.NewBuffer(nil) + term := &FakeTerminal{ + out: out, + Terminal: ioutils.NewTerminal(nil, func() io.Writer { + return out + }), + } + return term +} + +// Tee uses the given `out` as a secondary output. +// Usage: `Tee(os.Stdout)` to see in the console what's record in this terminal during the tests +// Note: it should be configured at the beginning of a test +func (t *FakeTerminal) Tee(out io.Writer) { + t.Terminal = ioutils.NewTerminal(t.Terminal.InOrStdin, func() io.Writer { + return io.MultiWriter(t.out, out) + }) +} + +// NewFakeTerminalWithResponse returns a new FakeTerminal which will +// print messages in the console and respond to the questions/confirmations +// with the given response +func NewFakeTerminalWithResponse(response string) *FakeTerminal { + out := bytes.NewBuffer(nil) + term := &FakeTerminal{ + out: out, + Terminal: ioutils.NewTerminal( + func() io.Reader { + in := bytes.NewBuffer(nil) + in.WriteString(response) + in.WriteByte('\n') + return in + }, + func() io.Writer { + return out + }, + ), + } + return term +} + +// Output return the content of the output buffer +func (t *FakeTerminal) Output() string { + return t.out.String() +} diff --git a/pkg/test/object_assertions.go b/pkg/test/object_assertions.go new file mode 100644 index 0000000..a08a032 --- /dev/null +++ b/pkg/test/object_assertions.go @@ -0,0 +1,227 @@ +package test + +import ( + "context" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + + olmv1 "github.com/operator-framework/api/pkg/operators/v1" + olmv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +// AssertCatalogSourceHasSpec verifies that the there is a CatalogSource resource matching the expected namespace/name, and with the same specs. +func AssertCatalogSourceHasSpec(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, expected olmv1alpha1.CatalogSourceSpec) { + actual := &olmv1alpha1.CatalogSource{} + AssertObjectHasContent(t, fakeClient, namespacedName, actual, func() { + assert.Equal(t, expected, actual.Spec) + }) +} + +// AssertCatalogSourceExists verifies that the there is a CatalogSource resource matching the expected namespace/name +func AssertCatalogSourceExists(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + actual := &olmv1alpha1.CatalogSource{} + AssertObjectExists(t, fakeClient, namespacedName, actual) +} + +// AssertCatalogSourceDoesNotExist verifies that there is no CatalogSource resource with the given namespace/name +func AssertCatalogSourceDoesNotExist(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + AssertObjectDoesNotExist(t, fakeClient, namespacedName, &olmv1alpha1.CatalogSource{}) +} + +// AssertOperatorGroupHasSpec verifies that the there is an OperatorGroup resource matching the expected namespace/name, and with the same specs. +func AssertOperatorGroupHasSpec(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, expected olmv1.OperatorGroupSpec) { + actual := &olmv1.OperatorGroup{} + AssertObjectHasContent(t, fakeClient, namespacedName, actual, func() { + assert.Equal(t, expected, actual.Spec) + }) +} + +// AssertOperatorGroupExists verifies that the there is a OperatorGroup resource matching the expected namespace/name +func AssertOperatorGroupExists(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + actual := &olmv1.OperatorGroup{} + AssertObjectExists(t, fakeClient, namespacedName, actual) +} + +func AssertOperatorGroupHasLabels(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, expected map[string]string) { + actual := &olmv1.OperatorGroup{} + AssertObjectHasContent(t, fakeClient, namespacedName, actual, func() { + assert.Equal(t, expected, actual.Labels) + }) +} + +// AssertOperatorGroupDoesNotExist verifies that there is no OperatorGroup resource with the given namespace/name +func AssertOperatorGroupDoesNotExist(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + AssertObjectDoesNotExist(t, fakeClient, namespacedName, &olmv1.OperatorGroup{}) +} + +// AssertSubscriptionHasSpec verifies that the there is a Subscription resource matching the expected namespace/name, and with the same specs. +func AssertSubscriptionHasSpec(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, expected *olmv1alpha1.SubscriptionSpec) { + actual := &olmv1alpha1.Subscription{} + AssertObjectHasContent(t, fakeClient, namespacedName, actual, func() { + assert.Equal(t, expected, actual.Spec) + }) +} + +// AssertSubscriptionExists verifies that the there is a Subscription resource matching the expected namespace/name +func AssertSubscriptionExists(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + actual := &olmv1alpha1.Subscription{} + AssertObjectExists(t, fakeClient, namespacedName, actual) +} + +// AssertSubscriptionDoesNotExist verifies that there is no Subscription resource with the given namespace/name +func AssertSubscriptionDoesNotExist(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + AssertObjectDoesNotExist(t, fakeClient, namespacedName, &olmv1alpha1.Subscription{}) +} + +// AssertToolchainConfigHasSpec verifies that there is an ToolchainConfig resource matching the expected namespace/name, and with the same spec. +func AssertToolchainConfigHasSpec(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, expected toolchainv1alpha1.ToolchainConfigSpec) { + actual := &toolchainv1alpha1.ToolchainConfig{} + AssertObjectHasContent(t, fakeClient, namespacedName, actual, func() { + assert.Equal(t, expected, actual.Spec) + }) +} + +// AssertToolchainConfigExists verifies that the there is an ToolchainConfig resource matching the expected namespace/name +func AssertToolchainConfigExists(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + actual := &toolchainv1alpha1.ToolchainConfig{} + AssertObjectExists(t, fakeClient, namespacedName, actual) +} + +// AssertToolchainConfigDoesNotExist verifies that there is no ToolchainConfig resource with the given namespace/name +func AssertToolchainConfigDoesNotExist(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + AssertObjectDoesNotExist(t, fakeClient, namespacedName, &toolchainv1alpha1.ToolchainConfig{}) +} + +// AssertConfigMapHasData verifies that the there is a ConfigMap resource matching the expected namespace/name, and with the same data. +func AssertConfigMapHasData(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, expected map[string]string) { + actual := &corev1.ConfigMap{} + AssertObjectHasContent(t, fakeClient, namespacedName, actual, func() { + assert.Equal(t, expected, actual.Data) + }) +} + +// AssertConfigMapExists verifies that the there is a ConfigMap resource matching the expected namespace/name +func AssertConfigMapExists(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + actual := &corev1.ConfigMap{} + AssertObjectExists(t, fakeClient, namespacedName, actual) +} + +// AssertConfigMapHasDataEntries verifies that the there is a ConfigMap resource matching the expected namespace/name, and with the given entries in its `data`. +func AssertConfigMapHasDataEntries(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, expectedEntries ...string) { + actual := &corev1.ConfigMap{} + err := fakeClient.Get(context.TODO(), namespacedName, actual) + require.NoError(t, err) + require.Len(t, actual.Data, len(expectedEntries)) + for _, e := range expectedEntries { + assert.Contains(t, actual.Data, e) + } +} + +// AssertConfigMapDoesNotExist verifies that there is no ConfigMap resource with the given namespace/name +func AssertConfigMapDoesNotExist(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + AssertObjectDoesNotExist(t, fakeClient, namespacedName, &corev1.ConfigMap{}) +} + +// AssertSecretHasData verifies that the there is a Secret resource matching the expected namespace/name, and with the same data. +func AssertSecretHasData(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, expected map[string][]byte) { + actual := &corev1.Secret{} + AssertObjectHasContent(t, fakeClient, namespacedName, actual, func() { + assert.Equal(t, expected, actual.Data) + }) +} + +// AssertSecretExists verifies that the there is a Secret resource matching the expected namespace/name +func AssertSecretExists(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + actual := &corev1.Secret{} + AssertObjectExists(t, fakeClient, namespacedName, actual) +} + +// AssertSecretHasDataEntries verifies that the there is a Secret resource matching the expected namespace/name, and with the given entries in its `data`. +func AssertSecretHasDataEntries(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, expectedEntries ...string) map[string][]byte { + actual := &corev1.Secret{} + err := fakeClient.Get(context.TODO(), namespacedName, actual) + require.NoError(t, err) + require.Len(t, actual.Data, len(expectedEntries)) + for _, e := range expectedEntries { + assert.Contains(t, actual.Data, e) + } + return actual.Data +} + +// AssertSecretDoesNotExist verifies that there is no Secret resource with the given namespace/name +func AssertSecretDoesNotExist(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName) { + AssertObjectDoesNotExist(t, fakeClient, namespacedName, &corev1.Secret{}) +} + +func AssertServiceAccountHasImagePullSecret(t *testing.T, fakeClient runtimeclient.Client, saNamespacedName types.NamespacedName, secretName string) { + actual := &corev1.ServiceAccount{} + err := fakeClient.Get(context.TODO(), saNamespacedName, actual) + require.NoError(t, err) + assert.Contains(t, actual.ImagePullSecrets, corev1.LocalObjectReference{Name: secretName}) +} + +// AssertDeploymentHasReplicas verifies that the there is a Deployment resource matching the expected namespace/name, and with the same spec.replicas. +func AssertDeploymentHasReplicas(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, replicas int32) { + actual := &appsv1.Deployment{} + AssertObjectHasContent(t, fakeClient, namespacedName, actual, func() { + require.NotNil(t, actual.Spec.Replicas) + assert.Equal(t, replicas, *actual.Spec.Replicas) + }) +} + +// ObjectAssertion is a type of function that should assert a content of an object +type ObjectAssertion func(t *testing.T, fakeClient *test.FakeClient) + +// ObjectExists checks that the given object exists and executes the given content assertion +func ObjectExists(namespace, name string, actualResource runtimeclient.Object, contentAssertion func(t *testing.T)) ObjectAssertion { + return func(t *testing.T, fakeClient *test.FakeClient) { + AssertObjectHasContent(t, fakeClient, test.NamespacedName(namespace, name), actualResource, func() { + contentAssertion(t) + }) + } +} + +// ObjectDoesNotExists checks that the given object does not exist +func ObjectDoesNotExists(namespace, name string, actualResource runtimeclient.Object) ObjectAssertion { + return func(t *testing.T, fakeClient *test.FakeClient) { + AssertObjectDoesNotExist(t, fakeClient, test.NamespacedName(namespace, name), actualResource) + } +} + +// AssertObjects executes all given object assertions +func AssertObjects(t *testing.T, fakeClient *test.FakeClient, objectAssertions ...ObjectAssertion) { + for _, assertObject := range objectAssertions { + assertObject(t, fakeClient) + } +} + +// AssertObjectHasContent verifies that the there is a resource matching the expected namespace/name, and with the same specs. +func AssertObjectHasContent(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, actualResource runtimeclient.Object, contentAssertions ...func()) { + err := fakeClient.Get(context.TODO(), namespacedName, actualResource) + require.NoError(t, err) + for _, assertContent := range contentAssertions { + assertContent() + } +} + +// AssertObjectExists verifies that there is a resource of the given type and with the given namespace/name +func AssertObjectExists(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, resource runtimeclient.Object) { + err := fakeClient.Get(context.TODO(), namespacedName, resource) + require.NoError(t, err) +} + +// AssertObjectDoesNotExist verifies that there is no resource of the given type and with the given namespace/name +func AssertObjectDoesNotExist(t *testing.T, fakeClient runtimeclient.Client, namespacedName types.NamespacedName, resource runtimeclient.Object) { + err := fakeClient.Get(context.TODO(), namespacedName, resource) + require.Error(t, err) + require.True(t, apierrors.IsNotFound(err)) +} diff --git a/pkg/test/toolchaincluster.go b/pkg/test/toolchaincluster.go new file mode 100644 index 0000000..27683f2 --- /dev/null +++ b/pkg/test/toolchaincluster.go @@ -0,0 +1,53 @@ +package test + +import ( + "context" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func NewToolchainCluster(modifiers ...ToolchainClusterModifier) *toolchainv1alpha1.ToolchainCluster { + toolchainCluster := &toolchainv1alpha1.ToolchainCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "member1", + Namespace: test.HostOperatorNs, + }, + Spec: toolchainv1alpha1.ToolchainClusterSpec{ + APIEndpoint: "https://api.member.com:6443", + CABundle: "somebundle", + SecretRef: toolchainv1alpha1.LocalSecretReference{}, + }, + } + for _, modify := range modifiers { + modify(toolchainCluster) + } + return toolchainCluster +} + +func AssertToolchainClusterDoesNotExist(t *testing.T, fakeClient *test.FakeClient, toolchainCluster *toolchainv1alpha1.ToolchainCluster) { + deletedCluster := &toolchainv1alpha1.ToolchainCluster{} + err := fakeClient.Get(context.TODO(), test.NamespacedName(toolchainCluster.Namespace, toolchainCluster.Name), deletedCluster) + require.True(t, apierrors.IsNotFound(err), "the ToolchainCluster should be deleted") +} + +func AssertToolchainClusterSpec(t *testing.T, fakeClient *test.FakeClient, expectedToolchainCluster *toolchainv1alpha1.ToolchainCluster) { + foundCluster := &toolchainv1alpha1.ToolchainCluster{} + err := fakeClient.Get(context.TODO(), test.NamespacedName(expectedToolchainCluster.Namespace, expectedToolchainCluster.Name), foundCluster) + require.NoError(t, err) + assert.Equal(t, expectedToolchainCluster.Spec, foundCluster.Spec) +} + +type ToolchainClusterModifier func(toolchainCluster *toolchainv1alpha1.ToolchainCluster) + +func ToolchainClusterName(name string) ToolchainClusterModifier { + return func(toolchainCluster *toolchainv1alpha1.ToolchainCluster) { + toolchainCluster.Name = name + } +} diff --git a/pkg/test/usersignup.go b/pkg/test/usersignup.go new file mode 100644 index 0000000..a629366 --- /dev/null +++ b/pkg/test/usersignup.go @@ -0,0 +1,119 @@ +package test + +import ( + "context" + "testing" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/toolchain-common/pkg/states" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + + uuid "github.com/satori/go.uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func NewUserSignup(modifiers ...UserSignupModifier) *toolchainv1alpha1.UserSignup { + signup := &toolchainv1alpha1.UserSignup{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewV4().String(), + Namespace: test.HostOperatorNs, + Labels: map[string]string{ + toolchainv1alpha1.UserSignupUserEmailHashLabelKey: "fd2addbd8d82f0d2dc088fa122377eaa", + toolchainv1alpha1.UserSignupUserPhoneHashLabelKey: "354365c1e4a37b74ed5b12fdeeno", + }, + }, + Spec: toolchainv1alpha1.UserSignupSpec{ + IdentityClaims: toolchainv1alpha1.IdentityClaimsEmbedded{ + PreferredUsername: "foo@redhat.com", + PropagatedClaims: toolchainv1alpha1.PropagatedClaims{ + Email: "foo@redhat.com", + }, + }, + }, + } + states.SetVerificationRequired(signup, true) + for _, modify := range modifiers { + modify(signup) + } + return signup +} + +func AssertUserSignupSpec(t *testing.T, fakeClient *test.FakeClient, expectedUserSignup *toolchainv1alpha1.UserSignup) { + updatedSignup := &toolchainv1alpha1.UserSignup{} + err := fakeClient.Get(context.TODO(), test.NamespacedName(expectedUserSignup.Namespace, expectedUserSignup.Name), updatedSignup) + require.NoError(t, err) + if len(expectedUserSignup.Spec.States) == 0 { + expectedUserSignup.Spec.States = nil + } + assert.Equal(t, expectedUserSignup.Spec, updatedSignup.Spec) +} + +func AssertUserSignupDoesNotExist(t *testing.T, fakeClient *test.FakeClient, userSignup *toolchainv1alpha1.UserSignup) { + deletedUserSignup := &toolchainv1alpha1.UserSignup{} + err := fakeClient.Get(context.TODO(), test.NamespacedName(userSignup.Namespace, userSignup.Name), deletedUserSignup) + require.True(t, apierrors.IsNotFound(err), "the UserSignup should be deleted") +} + +func UserSignupCompleteCondition(status corev1.ConditionStatus, reason string) toolchainv1alpha1.Condition { + return toolchainv1alpha1.Condition{ + Type: toolchainv1alpha1.UserSignupComplete, + Status: status, + Reason: reason, + } +} + +type UserSignupModifier func(userSignup *toolchainv1alpha1.UserSignup) + +func UserSignupCompliantUsername(username string) UserSignupModifier { + return func(userSignup *toolchainv1alpha1.UserSignup) { + userSignup.Status.CompliantUsername = username + } +} + +func UserSignupTargetCluster(cluster string) UserSignupModifier { + return func(userSignup *toolchainv1alpha1.UserSignup) { + userSignup.Spec.TargetCluster = cluster + } +} + +func UserSignupDeactivated(deactivated bool) UserSignupModifier { + return func(userSignup *toolchainv1alpha1.UserSignup) { + states.SetDeactivated(userSignup, deactivated) + } +} + +func UserSignupStatusComplete(status corev1.ConditionStatus, reason string) UserSignupModifier { + return func(userSignup *toolchainv1alpha1.UserSignup) { + userSignup.Status.Conditions = []toolchainv1alpha1.Condition{UserSignupCompleteCondition(status, reason)} + } +} + +func UserSignupSetLabel(key, value string) UserSignupModifier { + return func(userSignup *toolchainv1alpha1.UserSignup) { + userSignup.Labels[key] = value + } +} + +func UserSignupRemoveLabel(key string) UserSignupModifier { + return func(userSignup *toolchainv1alpha1.UserSignup) { + delete(userSignup.Labels, key) + } +} + +func UserSignupAutomaticallyApproved(_ bool) UserSignupModifier { + return func(userSignup *toolchainv1alpha1.UserSignup) { + userSignup.Labels[toolchainv1alpha1.StateLabelKey] = string(toolchainv1alpha1.UserSignupStateLabelValueApproved) + userSignup.Spec.States = nil + } +} + +func UserSignupApprovedByAdmin(_ bool) UserSignupModifier { + return func(userSignup *toolchainv1alpha1.UserSignup) { + userSignup.Labels[toolchainv1alpha1.StateLabelKey] = string(toolchainv1alpha1.UserSignupStateLabelValueApproved) + states.SetApprovedManually(userSignup, true) + } +} diff --git a/pkg/utils/case.go b/pkg/utils/case.go new file mode 100644 index 0000000..6d79daf --- /dev/null +++ b/pkg/utils/case.go @@ -0,0 +1,33 @@ +package utils + +import ( + "regexp" + "strings" +) + +// KebabToCamelCase converts kebab case to camel case +func KebabToCamelCase(kebab string) (camelCase string) { + isToUpper := false + for _, runeValue := range kebab { + if isToUpper { + camelCase += strings.ToUpper(string(runeValue)) + isToUpper = false + } else { + if runeValue == '-' { + isToUpper = true + } else { + camelCase += string(runeValue) + } + } + } + return +} + +var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") +var matchAllCapAndNumbers = regexp.MustCompile("([a-z])([A-Z0-9])") + +func CamelCaseToKebabCase(str string) string { + kebab := matchFirstCap.ReplaceAllString(str, "${1}-${2}") + kebab = matchAllCapAndNumbers.ReplaceAllString(kebab, "${1}-${2}") + return strings.ToLower(kebab) +} diff --git a/pkg/utils/case_test.go b/pkg/utils/case_test.go new file mode 100644 index 0000000..3716855 --- /dev/null +++ b/pkg/utils/case_test.go @@ -0,0 +1,45 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCamelCaseToKebabCase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", ""}, + {"already-kebab", "already-kebab"}, + {"A", "a"}, + {"AA", "aa"}, + {"AaAa", "aa-aa"}, + {"someValue", "some-value"}, + {"someCoolValue", "some-cool-value"}, + {"member1", "member-1"}, + } + for _, test := range tests { + assert.Equal(t, test.expected, CamelCaseToKebabCase(test.input)) + } +} + +func TestKebabCaseToCamelCase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", ""}, + {"alreadyCamel", "alreadyCamel"}, + {"a", "a"}, + {"AA", "AA"}, + {"aa-aa", "aaAa"}, + {"some-value", "someValue"}, + {"some-cool-value", "someCoolValue"}, + {"member-1", "member1"}, + } + for _, test := range tests { + assert.Equal(t, test.expected, KebabToCamelCase(test.input)) + } +} diff --git a/pkg/utils/util.go b/pkg/utils/util.go new file mode 100644 index 0000000..261dec0 --- /dev/null +++ b/pkg/utils/util.go @@ -0,0 +1,11 @@ +package utils + +// Contains checks if the given slice of strings contains the given string +func Contains(slice []string, value string) bool { + for _, role := range slice { + if role == value { + return true + } + } + return false +} diff --git a/pkg/utils/util_test.go b/pkg/utils/util_test.go new file mode 100644 index 0000000..f03ccef --- /dev/null +++ b/pkg/utils/util_test.go @@ -0,0 +1,30 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContains(t *testing.T) { + // given + content := []string{"1", "2", "3"} + + t.Run("Contains", func(t *testing.T) { + + // when + ok := Contains(content, "2") + + // then + assert.True(t, ok) + }) + + t.Run("does not contain", func(t *testing.T) { + + // when + ok := Contains(content, "4") + + // then + assert.False(t, ok) + }) +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..d238eaf --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,17 @@ +package version + +import "fmt" + +// we can't have those variables filled by the `-ldflags="-X ..."` in the `cmd/manager` package because +// it's imported as `main` + +var ( + // Commit the commit hash corresponding to the code that was built. Can be suffixed with `-dirty` + Commit = "unknown" + // BuildTime the time of build of the binary + BuildTime = "unknown" +) + +func NewMessage() string { + return fmt.Sprintf("commit: '%s', build time: '%s'", Commit, BuildTime) +} diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go new file mode 100644 index 0000000..0e32428 --- /dev/null +++ b/pkg/version/version_test.go @@ -0,0 +1,19 @@ +package version_test + +import ( + "testing" + + "github.com/kubesaw/ksctl/pkg/version" + "github.com/stretchr/testify/assert" +) + +func TestVars(t *testing.T) { + // simply verify that the vars exist. + // They will be populated by the `-ldflags="-X ..."` at build time + assert.Equal(t, "unknown", version.Commit) + assert.Equal(t, "unknown", version.BuildTime) +} + +func TestVersionMessage(t *testing.T) { + assert.Equal(t, "commit: 'unknown', build time: 'unknown'", version.NewMessage()) +} diff --git a/resources/resources.go b/resources/resources.go new file mode 100644 index 0000000..b19b5b4 --- /dev/null +++ b/resources/resources.go @@ -0,0 +1,8 @@ +package resources + +import ( + "embed" +) + +//go:embed * +var Resources embed.FS diff --git a/resources/setup/roles/host.yaml b/resources/setup/roles/host.yaml new file mode 100644 index 0000000..50c990c --- /dev/null +++ b/resources/setup/roles/host.yaml @@ -0,0 +1,315 @@ +apiVersion: template.openshift.io/v1 +kind: Template +metadata: + name: host-roles +objects: + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: install-operator + labels: + provider: sandbox-sre + rules: + - apiGroups: + - operators.coreos.com + resources: + - "catalogsources" + - "operatorgroups" + - "subscriptions" + verbs: + - "get" + - "list" + - "create" + - "patch" + - "update" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: restart-deployment + labels: + provider: sandbox-sre + rules: + - apiGroups: + - apps + resources: + - deployments + verbs: + - "get" + - "list" + - "patch" + - "update" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: view-secrets + labels: + provider: sandbox-sre + rules: + - apiGroups: + - "" + resources: + - "secrets" + verbs: + - "get" + - "list" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: edit-secrets + labels: + provider: sandbox-sre + rules: + - apiGroups: + - "" + resources: + - "secrets" + verbs: + - "get" + - "list" + - "watch" + - "create" + - "update" + - "patch" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: configure-monitoring + labels: + provider: sandbox-sre + rules: + - apiGroups: + - "" + resources: + - "configmaps" + - "secrets" + - "serviceaccounts" + - "services" + verbs: + - "create" + - "update" + - "get" + - "list" + - apiGroups: + - "" + resources: + - "serviceaccounts/token" + verbs: + - "create" + - apiGroups: + - "apps" + resources: + - "deployments" + - "statefulsets" + verbs: + - "create" + - "update" + - "get" + - "list" + - apiGroups: + - "route.openshift.io" + resources: + - "routes" + verbs: + - "create" + - "update" + - "get" + - "list" + - apiGroups: + - "monitoring.coreos.com" + resources: + - "prometheuses" + - "prometheusrules" + - "servicemonitors" + verbs: + - "create" + - "update" + - "get" + - "list" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: add-space-users + labels: + provider: sandbox-sre + rules: + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "spacebindings" + verbs: + - "get" + - "list" + - "create" + - "delete" + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "masteruserrecords" + - "nstemplatetiers" + - "spaces" + verbs: + - "get" + - "list" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: approve-user + labels: + provider: sandbox-sre + rules: + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "usersignups" + verbs: + - "get" + - "list" + - "patch" + - "update" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: ban-user + labels: + provider: sandbox-sre + rules: + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "bannedusers" + verbs: + - "get" + - "list" + - "create" + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "usersignups" + verbs: + - "get" + - "list" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: deactivate-user + labels: + provider: sandbox-sre + rules: + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "usersignups" + verbs: + - "get" + - "list" + - "patch" + - "update" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: promote-user + labels: + provider: sandbox-sre + rules: + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "masteruserrecords" + - "spaces" + verbs: + - "get" + - "list" + - "patch" + - "update" + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "nstemplatetiers" + - "usertiers" + verbs: + - "get" + - "list" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: disable-user + labels: + provider: sandbox-sre + rules: + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "masteruserrecords" + verbs: + - "get" + - "list" + - "patch" + - "update" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: gdpr-delete + labels: + provider: sandbox-sre + rules: + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "usersignups" + verbs: + - "get" + - "list" + - "delete" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: retarget-user + labels: + provider: sandbox-sre + rules: + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "usersignups" + verbs: + - "get" + - "list" + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "spaces" + verbs: + - "get" + - "list" + - "patch" + - "update" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: create-social-event + labels: + provider: sandbox-sre + rules: + - apiGroups: + - toolchain.dev.openshift.com + resources: + - "socialevents" + verbs: + - "create" + - "get" + - "list" + - "delete" diff --git a/resources/setup/roles/member.yaml b/resources/setup/roles/member.yaml new file mode 100644 index 0000000..9f13b1b --- /dev/null +++ b/resources/setup/roles/member.yaml @@ -0,0 +1,168 @@ +apiVersion: template.openshift.io/v1 +kind: Template +metadata: + name: member-roles +objects: + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: install-operator + labels: + provider: sandbox-sre + rules: + - apiGroups: + - operators.coreos.com + resources: + - "catalogsources" + - "operatorgroups" + - "subscriptions" + verbs: + - "get" + - "list" + - "create" + - "patch" + - "update" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: approve-operator-update + labels: + provider: sandbox-sre + rules: + - apiGroups: + - operators.coreos.com + resources: + - "installplans" + verbs: + - "get" + - "list" + - "create" + - "patch" + - "update" + - "delete" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: restart-deployment + labels: + provider: sandbox-sre + rules: + - apiGroups: + - apps + resources: + - deployments + verbs: + - "get" + - "list" + - "patch" + - "update" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: view-secrets + labels: + provider: sandbox-sre + rules: + - apiGroups: + - "" + resources: + - "secrets" + verbs: + - "get" + - "list" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: edit-secrets + labels: + provider: sandbox-sre + rules: + - apiGroups: + - "" + resources: + - "secrets" + verbs: + - "get" + - "list" + - "watch" + - "create" + - "update" + - "patch" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: configure-monitoring + labels: + provider: sandbox-sre + rules: + - apiGroups: + - "" + resources: + - "configmaps" + - "secrets" + - "serviceaccounts" + - "services" + verbs: + - "create" + - "update" + - "get" + - "list" + - apiGroups: + - "" + resources: + - "serviceaccounts/token" + verbs: + - "create" + - apiGroups: + - "apps" + resources: + - "deployments" + - "statefulsets" + verbs: + - "create" + - "update" + - "get" + - "list" + - apiGroups: + - "route.openshift.io" + resources: + - "routes" + verbs: + - "create" + - "update" + - "get" + - "list" + - apiGroups: + - "monitoring.coreos.com" + resources: + - "prometheuses" + - "prometheusrules" + - "servicemonitors" + verbs: + - "create" + - "update" + - "get" + - "list" + +- kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: edit-csv + labels: + provider: sandbox-sre + rules: + - apiGroups: + - operators.coreos.com + resources: + - "clusterserviceversions" + verbs: + - "get" + - "list" + - "patch" + - "update" diff --git a/test-resources/dummy.openshiftapps.com/sandbox-config.yaml b/test-resources/dummy.openshiftapps.com/sandbox-config.yaml new file mode 100644 index 0000000..3851f00 --- /dev/null +++ b/test-resources/dummy.openshiftapps.com/sandbox-config.yaml @@ -0,0 +1,216 @@ +clusters: + host: + api: https://api.dummy-host.openshiftapps.com:6443 + members: + - api: https://api.dummy-m1.openshiftapps.com:6443 + name: member-1 + - api: https://api.dummy-m2.openshiftapps.com:6443 + name: member-2 + - api: https://api.dummy-m3.openshiftapps.com:6443 + name: member-3 + +serviceAccounts: + +- name: first-admin + host: + roleBindings: + - namespace: toolchain-host-operator + roles: + - install-operator + - restart-deployment + - approve-user + - view-secrets + - deactivate-user + - ban-user + - promote-user + - disable-user + - retarget-user + - gdpr-delete + - create-social-event + - add-space-users + clusterRoles: + - edit + - view + - namespace: openshift-customer-monitoring + roles: + - install-operator + - view-secrets + - configure-monitoring + clusterRoles: + - edit + - namespace: openshift-logging + roles: + - install-operator + clusterRoles: + - edit + member: + roleBindings: + - namespace: toolchain-member-operator + roles: + - install-operator + - restart-deployment + - view-secrets + clusterRoles: + - edit + - view + - namespace: openshift-customer-monitoring + roles: + - install-operator + - view-secrets + - configure-monitoring + clusterRoles: + - edit + - namespace: openshift-logging + roles: + - install-operator + clusterRoles: + - edit + - namespace: openshift-config-managed + roles: + - configure-monitoring + clusterRoles: + - edit + clusterRoleBindings: + clusterRoles: + - manage-console-resources + +- name: second-admin + host: + roleBindings: + - namespace: toolchain-host-operator + roles: + - approve-user + - view-secrets + - deactivate-user + - ban-user + - promote-user + - disable-user + - retarget-user + - gdpr-delete + - restart-deployment + - create-social-event + - add-space-users + clusterRoles: + - view + member: + roleBindings: + - namespace: toolchain-member-operator + roles: + - restart-deployment + - view-secrets + clusterRoles: + - view + +- name: viewer + host: + roleBindings: + - namespace: toolchain-host-operator + clusterRoles: + - view + member: + roleBindings: + - namespace: toolchain-member-operator + clusterRoles: + - view + +users: +- name: standard-user-admin + id: + - 123456 + - abc1234 + groups: + - crtadmin-users-view + - inspect-pods + host: + roleBindings: + - namespace: toolchain-host-operator + roles: + - edit-secrets + clusterRoles: + - view + - namespace: openshift-customer-monitoring + roles: + - install-operator + - view-secrets + - configure-monitoring + clusterRoles: + - edit + - namespace: openshift-logging + clusterRoles: + - view + - namespace: sandbox-sre-host + roles: + - view-secrets + clusterRoles: + - view + member: + roleBindings: + - namespace: toolchain-member-operator + roles: + - edit-secrets + clusterRoles: + - view + - namespace: crw + roles: + - view-secrets + clusterRoles: + - view + - namespace: openshift-customer-monitoring + roles: + - install-operator + - view-secrets + - configure-monitoring + clusterRoles: + - edit + - namespace: openshift-logging + clusterRoles: + - view + - namespace: sandbox-sre-member + roles: + - view-secrets + clusterRoles: + - view + +- name: standard-user-viewer + id: + - 987654 + groups: + - crtadmin-users-view + - kubesaw-team + host: + roleBindings: + - namespace: toolchain-host-operator + clusterRoles: + - view + member: + roleBindings: + - namespace: toolchain-member-operator + clusterRoles: + - view + +- name: other-component-admin + id: + - 561234287 + - f:528d:some-admin + member: + roleBindings: + - namespace: some-component + roles: + - approve-operator-update + clusterRoles: + - edit + clusterRoleBindings: + clusterRoles: + - list-operators-group + +- name: other-component-viewer + id: + - 5412345 + member: + roleBindings: + - namespace: first-component + clusterRoles: + - view + - namespace: second-component + clusterRoles: + - view