From d1262147212c0922c19401235d87a7c63aeda80d Mon Sep 17 00:00:00 2001 From: Bhargav Ravuri Date: Mon, 28 Oct 2024 04:19:57 +0530 Subject: [PATCH 1/4] refactor(helm test): Pass extra args with helm tests Pass these extra arguments in Helm test cases: 1. REST client getter to be used with Helm action config 2. Kube client options for be used with fake printer kube client Related to #12722 Signed-off-by: Bhargav Ravuri --- cmd/helm/completion_test.go | 2 +- cmd/helm/create_test.go | 7 +++-- cmd/helm/dependency_build_test.go | 10 +++--- cmd/helm/dependency_update_test.go | 18 +++++++---- cmd/helm/helm_test.go | 49 +++++++++++++++++++++++------- cmd/helm/package_test.go | 4 +-- cmd/helm/pull_test.go | 4 +-- cmd/helm/repo_add_test.go | 2 +- cmd/helm/rollback_test.go | 2 +- cmd/helm/root_test.go | 4 +-- cmd/helm/search_hub_test.go | 6 ++-- cmd/helm/show_test.go | 3 +- cmd/helm/upgrade_test.go | 22 +++++++------- cmd/helm/verify_test.go | 2 +- pkg/kube/fake/printer.go | 9 +++++- 15 files changed, 92 insertions(+), 52 deletions(-) diff --git a/cmd/helm/completion_test.go b/cmd/helm/completion_test.go index 1143d644516..6ec9030e96f 100644 --- a/cmd/helm/completion_test.go +++ b/cmd/helm/completion_test.go @@ -41,7 +41,7 @@ func checkFileCompletion(t *testing.T, cmdName string, shouldBePerformed bool) { }) testcmd := fmt.Sprintf("__complete %s ''", cmdName) - _, out, err := executeActionCommandC(storage, testcmd) + _, out, err := executeActionCommandC(storage, testcmd, nil, nil) if err != nil { t.Errorf("unexpected error, %s", err) } diff --git a/cmd/helm/create_test.go b/cmd/helm/create_test.go index 1a22d058fd0..22caa6737b2 100644 --- a/cmd/helm/create_test.go +++ b/cmd/helm/create_test.go @@ -36,7 +36,7 @@ func TestCreateCmd(t *testing.T) { defer testChdir(t, dir)() // Run a create - if _, _, err := executeActionCommand("create " + cname); err != nil { + if _, _, err := executeActionCommand("create "+cname, nil, nil); err != nil { t.Fatalf("Failed to run create: %s", err) } @@ -81,7 +81,7 @@ func TestCreateStarterCmd(t *testing.T) { } // Run a create - if _, _, err := executeActionCommand(fmt.Sprintf("create --starter=starterchart %s", cname)); err != nil { + if _, _, err := executeActionCommand(fmt.Sprintf("create --starter=starterchart %s", cname), nil, nil); err != nil { t.Errorf("Failed to run create: %s", err) return } @@ -149,7 +149,8 @@ func TestCreateStarterAbsoluteCmd(t *testing.T) { starterChartPath := filepath.Join(starterchart, "starterchart") // Run a create - if _, _, err := executeActionCommand(fmt.Sprintf("create --starter=%s %s", starterChartPath, cname)); err != nil { + _, _, err := executeActionCommand(fmt.Sprintf("create --starter=%s %s", starterChartPath, cname), nil, nil) + if err != nil { t.Errorf("Failed to run create: %s", err) return } diff --git a/cmd/helm/dependency_build_test.go b/cmd/helm/dependency_build_test.go index 37e3242c49f..d8ec5fd9805 100644 --- a/cmd/helm/dependency_build_test.go +++ b/cmd/helm/dependency_build_test.go @@ -59,7 +59,7 @@ func TestDependencyBuildCmd(t *testing.T) { repoFile := filepath.Join(rootDir, "repositories.yaml") cmd := fmt.Sprintf("dependency build '%s' --repository-config %s --repository-cache %s", filepath.Join(rootDir, chartname), repoFile, rootDir) - _, out, err := executeActionCommand(cmd) + _, out, err := executeActionCommand(cmd, nil, nil) // In the first pass, we basically want the same results as an update. if err != nil { @@ -87,7 +87,7 @@ func TestDependencyBuildCmd(t *testing.T) { t.Fatal(err) } - _, out, err = executeActionCommand(cmd) + _, out, err = executeActionCommand(cmd, nil, nil) if err != nil { t.Logf("Output: %s", out) t.Fatal(err) @@ -118,7 +118,7 @@ func TestDependencyBuildCmd(t *testing.T) { } skipRefreshCmd := fmt.Sprintf("dependency build '%s' --skip-refresh --repository-config %s --repository-cache %s", filepath.Join(rootDir, chartname), repoFile, rootDir) - _, out, err = executeActionCommand(skipRefreshCmd) + _, out, err = executeActionCommand(skipRefreshCmd, nil, nil) // In this pass, we check --skip-refresh option becomes effective. if err != nil { @@ -139,7 +139,7 @@ func TestDependencyBuildCmd(t *testing.T) { dir("repositories.yaml"), dir(), dir()) - _, out, err = executeActionCommand(cmd) + _, out, err = executeActionCommand(cmd, nil, nil) if err != nil { t.Logf("Output: %s", out) t.Fatal(err) @@ -154,7 +154,7 @@ func TestDependencyBuildCmdWithHelmV2Hash(t *testing.T) { chartName := "testdata/testcharts/issue-7233" cmd := fmt.Sprintf("dependency build '%s'", chartName) - _, out, err := executeActionCommand(cmd) + _, out, err := executeActionCommand(cmd, nil, nil) // Want to make sure the build can verify Helm v2 hash if err != nil { diff --git a/cmd/helm/dependency_update_test.go b/cmd/helm/dependency_update_test.go index 1a1e0468f07..0178bcccf82 100644 --- a/cmd/helm/dependency_update_test.go +++ b/cmd/helm/dependency_update_test.go @@ -68,7 +68,7 @@ func TestDependencyUpdateCmd(t *testing.T) { _, out, err := executeActionCommand( fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir()), - ) + nil, nil) if err != nil { t.Logf("Output: %s", out) t.Fatal(err) @@ -110,7 +110,9 @@ func TestDependencyUpdateCmd(t *testing.T) { t.Fatal(err) } - _, out, err = executeActionCommand(fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir())) + _, out, err = executeActionCommand( + fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir()), + nil, nil) if err != nil { t.Logf("Output: %s", out) t.Fatal(err) @@ -136,7 +138,7 @@ func TestDependencyUpdateCmd(t *testing.T) { dir("repositories.yaml"), dir(), dir()) - _, out, err = executeActionCommand(cmd) + _, out, err = executeActionCommand(cmd, nil, nil) if err != nil { t.Logf("Output: %s", out) t.Fatal(err) @@ -169,7 +171,9 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) { } createTestingChart(t, dir(), chartname, srv.URL()) - _, output, err := executeActionCommand(fmt.Sprintf("dependency update %s --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir())) + _, output, err := executeActionCommand( + fmt.Sprintf("dependency update %s --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir()), + nil, nil) if err != nil { t.Logf("Output: %s", output) t.Fatal(err) @@ -178,7 +182,9 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) { // Chart repo is down srv.Stop() - _, output, err = executeActionCommand(fmt.Sprintf("dependency update %s --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir())) + _, output, err = executeActionCommand( + fmt.Sprintf("dependency update %s --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir()), + nil, nil) if err == nil { t.Logf("Output: %s", output) t.Fatal("Expected error, got nil") @@ -233,7 +239,7 @@ func TestDependencyUpdateCmd_WithRepoThatWasNotAdded(t *testing.T) { _, out, err := executeActionCommand( fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir()), - ) + nil, nil) if err != nil { t.Logf("Output: %s", out) diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go index 7d0bf57516a..810ade20959 100644 --- a/cmd/helm/helm_test.go +++ b/cmd/helm/helm_test.go @@ -59,7 +59,12 @@ func runTestCmd(t *testing.T, tests []cmdTestCase) { } } t.Logf("running cmd (attempt %d): %s", i+1, tt.cmd) - _, out, err := executeActionCommandC(storage, tt.cmd) + _, out, err := executeActionCommandC( + storage, + tt.cmd, + tt.restClientGetter, + tt.kubeClientOpts, + ) if tt.wantError && err == nil { t.Errorf("expected error, got success with the following output:\n%s", out) } @@ -78,11 +83,22 @@ func storageFixture() *storage.Storage { return storage.Init(driver.NewMemory()) } -func executeActionCommandC(store *storage.Storage, cmd string) (*cobra.Command, string, error) { - return executeActionCommandStdinC(store, nil, cmd) +func executeActionCommandC( + store *storage.Storage, + cmd string, + restClientGetter action.RESTClientGetter, + kubeClientOpts *kubefake.Options, +) (*cobra.Command, string, error) { + return executeActionCommandStdinC(store, nil, cmd, restClientGetter, kubeClientOpts) } -func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) (*cobra.Command, string, error) { +func executeActionCommandStdinC( + store *storage.Storage, + in *os.File, + cmd string, + restClientGetter action.RESTClientGetter, + kubeClientOpts *kubefake.Options, +) (*cobra.Command, string, error) { args, err := shellwords.Parse(cmd) if err != nil { return nil, "", err @@ -91,10 +107,14 @@ func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) buf := new(bytes.Buffer) actionConfig := &action.Configuration{ - Releases: store, - KubeClient: &kubefake.PrintingKubeClient{Out: io.Discard}, - Capabilities: chartutil.DefaultCapabilities, - Log: func(_ string, _ ...interface{}) {}, + Releases: store, + KubeClient: &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: kubeClientOpts, + }, + Capabilities: chartutil.DefaultCapabilities, + Log: func(_ string, _ ...interface{}) {}, + RESTClientGetter: restClientGetter, } root, err := newRootCmd(actionConfig, buf, args) @@ -135,10 +155,18 @@ type cmdTestCase struct { // Number of repeats (in case a feature was previously flaky and the test checks // it's now stably producing identical results). 0 means test is run exactly once. repeat int + // REST client getter to be used with Helm action config + restClientGetter action.RESTClientGetter + // Kube client options for be used with fake printer kube client + kubeClientOpts *kubefake.Options } -func executeActionCommand(cmd string) (*cobra.Command, string, error) { - return executeActionCommandC(storageFixture(), cmd) +func executeActionCommand( + cmd string, + restClientGetter action.RESTClientGetter, + kubeClintOpts *kubefake.Options, +) (*cobra.Command, string, error) { + return executeActionCommandC(storageFixture(), cmd, restClientGetter, kubeClintOpts) } func resetEnv() func() { @@ -201,7 +229,6 @@ func TestPluginExitCode(t *testing.T) { cmd.Stderr = stderr err := cmd.Run() exiterr, ok := err.(*exec.ExitError) - if !ok { t.Fatalf("Unexpected error returned by os.Exit: %T", err) } diff --git a/cmd/helm/package_test.go b/cmd/helm/package_test.go index 9093b510cfd..0b698cd81e9 100644 --- a/cmd/helm/package_test.go +++ b/cmd/helm/package_test.go @@ -138,7 +138,7 @@ func TestPackage(t *testing.T) { cmd = append(cmd, fmt.Sprintf("--%s=%s", k, v)) } } - _, _, err = executeActionCommand(strings.Join(cmd, " ")) + _, _, err = executeActionCommand(strings.Join(cmd, " "), nil, nil) if err != nil { if tt.err && re.MatchString(err.Error()) { return @@ -171,7 +171,7 @@ func TestSetAppVersion(t *testing.T) { chartToPackage := "testdata/testcharts/alpine" dir := t.TempDir() cmd := fmt.Sprintf("package %s --destination=%s --app-version=%s", chartToPackage, dir, expectedAppVersion) - _, output, err := executeActionCommand(cmd) + _, output, err := executeActionCommand(cmd, nil, nil) if err != nil { t.Logf("Output: %s", output) t.Fatal(err) diff --git a/cmd/helm/pull_test.go b/cmd/helm/pull_test.go index ae70595f933..3709d489ee2 100644 --- a/cmd/helm/pull_test.go +++ b/cmd/helm/pull_test.go @@ -220,7 +220,7 @@ func TestPullCmd(t *testing.T) { t.Fatal(err) } } - _, out, err := executeActionCommand(cmd) + _, out, err := executeActionCommand(cmd, nil, nil) if err != nil { if tt.wantError { if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() { @@ -337,7 +337,7 @@ func TestPullWithCredentialsCmd(t *testing.T) { t.Fatal(err) } } - _, _, err := executeActionCommand(cmd) + _, _, err := executeActionCommand(cmd, nil, nil) if err != nil { if tt.wantError { if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() { diff --git a/cmd/helm/repo_add_test.go b/cmd/helm/repo_add_test.go index 2386bb01fa0..f501d88793b 100644 --- a/cmd/helm/repo_add_test.go +++ b/cmd/helm/repo_add_test.go @@ -262,7 +262,7 @@ func TestRepoAddWithPasswordFromStdin(t *testing.T) { const username = "username" cmd := fmt.Sprintf("repo add %s %s --repository-config %s --repository-cache %s --username %s --password-stdin", testName, srv.URL(), repoFile, tmpdir, username) var result string - _, result, err = executeActionCommandStdinC(store, in, cmd) + _, result, err = executeActionCommandStdinC(store, in, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } diff --git a/cmd/helm/rollback_test.go b/cmd/helm/rollback_test.go index b58e4c162f3..f3273600b58 100644 --- a/cmd/helm/rollback_test.go +++ b/cmd/helm/rollback_test.go @@ -151,7 +151,7 @@ func TestRollbackWithLabels(t *testing.T) { t.Fatal(err) } } - _, _, err := executeActionCommandC(storage, fmt.Sprintf("rollback %s 1", releaseName)) + _, _, err := executeActionCommandC(storage, fmt.Sprintf("rollback %s 1", releaseName), nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } diff --git a/cmd/helm/root_test.go b/cmd/helm/root_test.go index 65e6d66c711..25edac56510 100644 --- a/cmd/helm/root_test.go +++ b/cmd/helm/root_test.go @@ -83,7 +83,7 @@ func TestRootCmd(t *testing.T) { os.Setenv(k, v) } - if _, _, err := executeActionCommand(tt.args); err != nil { + if _, _, err := executeActionCommand(tt.args, nil, nil); err != nil { t.Fatalf("unexpected error: %s", err) } @@ -115,7 +115,7 @@ func TestRootCmd(t *testing.T) { } func TestUnknownSubCmd(t *testing.T) { - _, _, err := executeActionCommand("foobar") + _, _, err := executeActionCommand("foobar", nil, nil) if err == nil || err.Error() != `unknown command "foobar" for "helm"` { t.Errorf("Expect unknown command error, got %q", err) diff --git a/cmd/helm/search_hub_test.go b/cmd/helm/search_hub_test.go index f3730275a8f..711165f5ecf 100644 --- a/cmd/helm/search_hub_test.go +++ b/cmd/helm/search_hub_test.go @@ -42,7 +42,7 @@ func TestSearchHubCmd(t *testing.T) { testcmd := "search hub --endpoint " + ts.URL + " maria" storage := storageFixture() - _, out, err := executeActionCommandC(storage, testcmd) + _, out, err := executeActionCommandC(storage, testcmd, nil, nil) if err != nil { t.Errorf("unexpected error, %s", err) } @@ -72,7 +72,7 @@ func TestSearchHubListRepoCmd(t *testing.T) { testcmd := "search hub --list-repo-url --endpoint " + ts.URL + " maria" storage := storageFixture() - _, out, err := executeActionCommandC(storage, testcmd) + _, out, err := executeActionCommandC(storage, testcmd, nil, nil) if err != nil { t.Errorf("unexpected error, %s", err) } @@ -165,7 +165,7 @@ func TestSearchHubCmd_FailOnNoResponseTests(t *testing.T) { storage := storageFixture() - _, out, err := executeActionCommandC(storage, tt.cmd) + _, out, err := executeActionCommandC(storage, tt.cmd, nil, nil) if tt.wantErr { if err == nil { t.Errorf("expected error due to no record in response, got nil") diff --git a/cmd/helm/show_test.go b/cmd/helm/show_test.go index 93ec08d0f6b..05782f6dbd8 100644 --- a/cmd/helm/show_test.go +++ b/cmd/helm/show_test.go @@ -73,8 +73,7 @@ func TestShowPreReleaseChart(t *testing.T) { filepath.Join(outdir, "repositories.yaml"), outdir, ) - //_, out, err := executeActionCommand(cmd) - _, _, err := executeActionCommand(cmd) + _, _, err := executeActionCommand(cmd, nil, nil) if err != nil { if tt.fail { if !strings.Contains(err.Error(), tt.expectedErr) { diff --git a/cmd/helm/upgrade_test.go b/cmd/helm/upgrade_test.go index 497c78d71c2..4f2ec3038e8 100644 --- a/cmd/helm/upgrade_test.go +++ b/cmd/helm/upgrade_test.go @@ -196,7 +196,7 @@ func TestUpgradeWithValue(t *testing.T) { store.Create(relMock(releaseName, 3, ch)) cmd := fmt.Sprintf("upgrade %s --set favoriteDrink=tea '%s'", releaseName, chartPath) - _, _, err := executeActionCommandC(store, cmd) + _, _, err := executeActionCommandC(store, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -223,7 +223,7 @@ func TestUpgradeWithStringValue(t *testing.T) { store.Create(relMock(releaseName, 3, ch)) cmd := fmt.Sprintf("upgrade %s --set-string favoriteDrink=coffee '%s'", releaseName, chartPath) - _, _, err := executeActionCommandC(store, cmd) + _, _, err := executeActionCommandC(store, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -251,7 +251,7 @@ func TestUpgradeInstallWithSubchartNotes(t *testing.T) { store.Create(relMock(releaseName, 1, ch)) cmd := fmt.Sprintf("upgrade %s -i --render-subchart-notes '%s'", releaseName, "testdata/testcharts/chart-with-subchart-notes") - _, _, err := executeActionCommandC(store, cmd) + _, _, err := executeActionCommandC(store, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -283,7 +283,7 @@ func TestUpgradeWithValuesFile(t *testing.T) { store.Create(relMock(releaseName, 3, ch)) cmd := fmt.Sprintf("upgrade %s --values testdata/testcharts/upgradetest/values.yaml '%s'", releaseName, chartPath) - _, _, err := executeActionCommandC(store, cmd) + _, _, err := executeActionCommandC(store, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -316,7 +316,7 @@ func TestUpgradeWithValuesFromStdin(t *testing.T) { } cmd := fmt.Sprintf("upgrade %s --values - '%s'", releaseName, chartPath) - _, _, err = executeActionCommandStdinC(store, in, cmd) + _, _, err = executeActionCommandStdinC(store, in, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -346,7 +346,7 @@ func TestUpgradeInstallWithValuesFromStdin(t *testing.T) { } cmd := fmt.Sprintf("upgrade %s -f - --install '%s'", releaseName, chartPath) - _, _, err = executeActionCommandStdinC(store, in, cmd) + _, _, err = executeActionCommandStdinC(store, in, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -450,7 +450,7 @@ func TestUpgradeInstallWithLabels(t *testing.T) { "key2": "val2", } cmd := fmt.Sprintf("upgrade %s --install --labels key1=val1,key2=val2 '%s'", releaseName, chartPath) - _, _, err := executeActionCommandC(store, cmd) + _, _, err := executeActionCommandC(store, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -515,7 +515,7 @@ func TestUpgradeWithDryRun(t *testing.T) { // First install a release into the store so that future --dry-run attempts // have it available. cmd := fmt.Sprintf("upgrade %s --install '%s'", releaseName, chartPath) - _, _, err := executeActionCommandC(store, cmd) + _, _, err := executeActionCommandC(store, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -526,7 +526,7 @@ func TestUpgradeWithDryRun(t *testing.T) { } cmd = fmt.Sprintf("upgrade %s --dry-run '%s'", releaseName, chartPath) - _, out, err := executeActionCommandC(store, cmd) + _, out, err := executeActionCommandC(store, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -543,7 +543,7 @@ func TestUpgradeWithDryRun(t *testing.T) { // Ensure the secret is not in the output cmd = fmt.Sprintf("upgrade %s --dry-run --hide-secret '%s'", releaseName, chartPath) - _, out, err = executeActionCommandC(store, cmd) + _, out, err = executeActionCommandC(store, cmd, nil, nil) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -560,7 +560,7 @@ func TestUpgradeWithDryRun(t *testing.T) { // Ensure there is an error when --hide-secret used without dry-run cmd = fmt.Sprintf("upgrade %s --hide-secret '%s'", releaseName, chartPath) - _, _, err = executeActionCommandC(store, cmd) + _, _, err = executeActionCommandC(store, cmd, nil, nil) if err == nil { t.Error("expected error when --hide-secret used without --dry-run") } diff --git a/cmd/helm/verify_test.go b/cmd/helm/verify_test.go index 23b7935577c..b907c121405 100644 --- a/cmd/helm/verify_test.go +++ b/cmd/helm/verify_test.go @@ -72,7 +72,7 @@ func TestVerifyCmd(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, out, err := executeActionCommand(tt.cmd) + _, out, err := executeActionCommand(tt.cmd, nil, nil) if tt.wantError { if err == nil { t.Errorf("Expected error, but got none: %q", out) diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go index cc2c84b40b8..d6a937c718c 100644 --- a/pkg/kube/fake/printer.go +++ b/pkg/kube/fake/printer.go @@ -29,10 +29,17 @@ import ( "helm.sh/helm/v3/pkg/kube" ) +// Options to control the fake behavior of PrintingKubeClient +type Options struct { + GetReturnResourceMap bool + BuildReturnResourceList bool +} + // PrintingKubeClient implements KubeClient, but simply prints the reader to // the given output. type PrintingKubeClient struct { - Out io.Writer + Out io.Writer + Options *Options } // IsReachable checks if the cluster is reachable From 7b467944699a5583cbd81fe50460075062c45795 Mon Sep 17 00:00:00 2001 From: Bhargav Ravuri Date: Mon, 28 Oct 2024 04:20:13 +0530 Subject: [PATCH 2/4] feat(get deployed): Add command: helm get deployed Add new command `helm get deployed` to list the resources in the release. Fixes #12722 Signed-off-by: Bhargav Ravuri --- cmd/helm/get.go | 1 + cmd/helm/get_deployed.go | 80 ++++++++++++ pkg/action/get_deployed.go | 241 +++++++++++++++++++++++++++++++++++++ pkg/kube/interface.go | 2 + 4 files changed, 324 insertions(+) create mode 100644 cmd/helm/get_deployed.go create mode 100644 pkg/action/get_deployed.go diff --git a/cmd/helm/get.go b/cmd/helm/get.go index 727cdaf88e5..cf72f421094 100644 --- a/cmd/helm/get.go +++ b/cmd/helm/get.go @@ -45,6 +45,7 @@ func newGetCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } cmd.AddCommand(newGetAllCmd(cfg, out)) + cmd.AddCommand(newGetDeployedCmd(cfg, out)) cmd.AddCommand(newGetValuesCmd(cfg, out)) cmd.AddCommand(newGetManifestCmd(cfg, out)) cmd.AddCommand(newGetHooksCmd(cfg, out)) diff --git a/cmd/helm/get_deployed.go b/cmd/helm/get_deployed.go new file mode 100644 index 00000000000..432ccc6c0e8 --- /dev/null +++ b/cmd/helm/get_deployed.go @@ -0,0 +1,80 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "io" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli/output" +) + +var getDeployedHelp = ` +This command prints list of resources deployed under a release. + +Example output: + + NAMESPACE NAME API_VERSION AGE + thousand-sunny services/zoro v1 2m + namespaces/thousand-sunny v1 2m + thousand-sunny configmaps/nami v1 2m + thousand-sunny deployments/luffy apps/v1 2m +` + +// newGetDeployedCmd creates a command for listing the resources deployed under a named release +func newGetDeployedCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + // Output format for the command output. This will be set by input flag -o (or --output). + var outfmt output.Format + + // Create get-deployed action's client + client := action.NewGetDeployed(cfg) + + cmd := &cobra.Command{ + Use: "deployed RELEASE_NAME", + Short: "list resources deployed under a named release", + Long: getDeployedHelp, + Args: require.ExactArgs(1), + ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return compListReleases(toComplete, args, cfg) + }, + RunE: func(_ *cobra.Command, args []string) error { + // Run the client to list resources under the release + resourceList, err := client.Run(args[0]) + if err != nil { + return err + } + + // Create an output writer with resources listed + writer := action.NewResourceListWriter(resourceList, false) + + // Write the resources list with output format provided with input flag + return outfmt.Write(out, writer) + }, + } + + // Add flag for specifying the output format + bindOutputFlag(cmd, &outfmt) + + return cmd +} diff --git a/pkg/action/get_deployed.go b/pkg/action/get_deployed.go new file mode 100644 index 00000000000..4b60390c8b0 --- /dev/null +++ b/pkg/action/get_deployed.go @@ -0,0 +1,241 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "bytes" + "fmt" + "io" + "strings" + + "helm.sh/helm/v3/pkg/cli/output" + + "github.com/gosuri/uitable" + "k8s.io/apimachinery/pkg/api/meta" + metatable "k8s.io/apimachinery/pkg/api/meta/table" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +// GetDeployed is the action for checking the named release's deployed resource list. It is the implementation +// of 'helm get deployed' subcommand. +// +// Example output: +// +// NAMESPACE NAME API_VERSION AGE +// namespaces/thousand-sunny v1 2m +// thousand-sunny configmaps/nami v1 2m +// thousand-sunny services/zoro v1 2m +// thousand-sunny deployments/luffy apps/v1 2m +type GetDeployed struct { + cfg *Configuration +} + +// NewGetDeployed creates a new GetDeployed object with the input configuration. +func NewGetDeployed(cfg *Configuration) *GetDeployed { + return &GetDeployed{ + cfg: cfg, + } +} + +// Run executes 'helm get deployed' against the named release. +func (g *GetDeployed) Run(name string) ([]ResourceElement, error) { + // Check if cluster is reachable from the client + if err := g.cfg.KubeClient.IsReachable(); err != nil { + return nil, fmt.Errorf("cluster is not reachable: %w", err) + } + + // Get the release details. The revision is set to 0 to get the latest revision of the release. + release, err := g.cfg.releaseContent(name, 0) + if err != nil { + return nil, fmt.Errorf("failed to fetch release content: %w", err) + } + + // Fetch the REST mapper + mapper, err := g.cfg.RESTClientGetter.ToRESTMapper() + if err != nil { + return nil, fmt.Errorf("failed to extract the REST mapper: %v", err) + } + + // Create function to iterate over all the resources in the release manifest + resourceList := make([]ResourceElement, 0) + listResourcesFn := kio.FilterFunc(func(resources []*yaml.RNode) ([]*yaml.RNode, error) { + // Iterate over the resource in manifest YAML + for _, manifest := range resources { + // Process resource record for "helm get deployed" + resource, err := g.processResourceRecord(manifest, mapper) + if err != nil { + return nil, err + } + + resourceList = append(resourceList, *resource) + } + + // The current command shouldn't alter the list of resources. Hence returning resources list as it. + return resources, nil + }) + + // Run the manifest YAML through the function to process the resources list + err = kio.Pipeline{ + Inputs: []kio.Reader{&kio.ByteReader{Reader: strings.NewReader(release.Manifest)}}, + Filters: []kio.Filter{listResourcesFn}, + }.Execute() + if err != nil { + return nil, fmt.Errorf("failed to process release manifests: %w", err) + } + + return resourceList, nil +} + +// processResourceRecord processes the manifest YAML node in the record format required for resourceListWriter (i.e, +// output of `helm get deployed` command). +func (g *GetDeployed) processResourceRecord(manifest *yaml.RNode, mapper meta.RESTMapper) (*ResourceElement, error) { + // Parse manifest YAML node as string + manifestStr, err := manifest.String() + if err != nil { + return nil, fmt.Errorf("failed to fetch the string format of the manifest: %v", err) + } + + // Build resource list required for Helm kube client + filter, err := g.cfg.KubeClient.Build(bytes.NewBufferString(manifestStr), false) + if err != nil { + return nil, fmt.Errorf("failed to build resource list: %v", err) + } + + // Fetch the resources from the Kubernetes cluster based on the resource list built above + // + // Note: processResourceRecord is for a single record/resource. However, Get() returns resources in a slice with + // the current record. + list, err := g.cfg.KubeClient.Get(filter, false) + if err != nil { + return nil, fmt.Errorf("failed to get the resource from cluster: %v", err) + } + + var ( + resourceObj runtime.Object + metaObj metav1.Object + ) + + // Extract the resource object and its metadata from the list of resources. Note: Though Get() returns a list of + // resources, it only consists of one resource matching the resource name since it is filtered based on a single + // resource's manifest. + err = func() error { + var ok bool + for _, objects := range list { + for _, obj := range objects { + metaObj, ok = obj.(metav1.Object) + if !ok { + return fmt.Errorf("object does not implement metav1.Object interface") + } + + if metaObj.GetName() != manifest.GetName() { + continue + } + + resourceObj = obj + + return nil + } + } + + return fmt.Errorf("failed to find resource %q in the list", manifest.GetName()) + }() + if err != nil { + return nil, fmt.Errorf("failed to get the REST mapping for the resource: %v", err) + } + + // Fetch the GVR mapping from Kubernetes REST mapper + resourceMapping, err := restMapping(resourceObj, mapper) + if err != nil { + return nil, fmt.Errorf("failed to get the REST mapping for the resource: %v", err) + } + + // Format resource record + return &ResourceElement{ + Resource: resourceMapping.Resource.Resource, + Name: manifest.GetName(), + Namespace: metaObj.GetNamespace(), + APIVersion: manifest.GetApiVersion(), + CreationTimestamp: metaObj.GetCreationTimestamp(), + }, nil +} + +// restMapping returns the GVR mapping from Kubernetes REST mapper +func restMapping(obj runtime.Object, mapper meta.RESTMapper) (*meta.RESTMapping, error) { + gvk := obj.GetObjectKind().GroupVersionKind() + + mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return nil, fmt.Errorf("failed to find RESTMapping: %v", err) + } + + return mapping, nil +} + +type ResourceElement struct { + Name string `json:"name"` // Resource's name + Namespace string `json:"namespace"` // Resource's namespace + APIVersion string `json:"apiVersion"` // Resource's group-version + Resource string `json:"resource"` // Resource type (eg. pods, deployments, etc.) + CreationTimestamp metav1.Time `json:"creationTimestamp"` // Resource creation timestamp +} + +type resourceListWriter struct { + releases []ResourceElement // Resources list + noHeaders bool // Toggle to disable headers in tabular format +} + +// NewResourceListWriter creates a output writer for Kubernetes resources to be listed with 'helm get deployed' +func NewResourceListWriter(resources []ResourceElement, noHeaders bool) output.Writer { + return &resourceListWriter{resources, noHeaders} +} + +// WriteTable prints the resources list in a tabular format +func (r *resourceListWriter) WriteTable(out io.Writer) error { + // Create table writer + table := uitable.New() + + // Add headers if enabled + if !r.noHeaders { + table.AddRow("NAMESPACE", "NAME", "API_VERSION", "AGE") + } + + // Add resources to table + for _, r := range r.releases { + table.AddRow( + r.Namespace, // Namespace + fmt.Sprintf("%s/%s", r.Resource, r.Name), // Name + r.APIVersion, // API version + metatable.ConvertToHumanReadableDateType(r.CreationTimestamp), // Age + ) + } + + // Format the table and write to output writer + return output.EncodeTable(out, table) +} + +// WriteTable prints the resources list in a JSON format +func (r *resourceListWriter) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, r.releases) +} + +// WriteTable prints the resources list in a YAML format +func (r *resourceListWriter) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, r.releases) +} diff --git a/pkg/kube/interface.go b/pkg/kube/interface.go index ce42ed9501d..4a167ed0a24 100644 --- a/pkg/kube/interface.go +++ b/pkg/kube/interface.go @@ -29,6 +29,8 @@ import ( // // A KubernetesClient must be concurrency safe. type Interface interface { + Get(resources ResourceList, related bool) (map[string][]runtime.Object, error) + // Create creates one or more resources. Create(resources ResourceList) (*Result, error) From 9810277c39a9d876eba3cb541137eabb19f8092d Mon Sep 17 00:00:00 2001 From: Bhargav Ravuri Date: Mon, 28 Oct 2024 04:20:17 +0530 Subject: [PATCH 3/4] test(get deployed): Add cmd/helm level tests Add cmd/helm level tests related to `helm get deployed` command. Related to #12722 Signed-off-by: Bhargav Ravuri --- cmd/helm/get_deployed_test.go | 209 +++++++++++++++++++++ cmd/helm/testdata/output/get-deployed.json | 1 + cmd/helm/testdata/output/get-deployed.txt | 5 + cmd/helm/testdata/output/get-deployed.yaml | 20 ++ pkg/kube/fake/printer.go | 153 ++++++++++++++- 5 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 cmd/helm/get_deployed_test.go create mode 100644 cmd/helm/testdata/output/get-deployed.json create mode 100644 cmd/helm/testdata/output/get-deployed.txt create mode 100644 cmd/helm/testdata/output/get-deployed.yaml diff --git a/cmd/helm/get_deployed_test.go b/cmd/helm/get_deployed_test.go new file mode 100644 index 00000000000..b5e3d11ec48 --- /dev/null +++ b/cmd/helm/get_deployed_test.go @@ -0,0 +1,209 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "bytes" + "fmt" + "testing" + "text/template" + "time" + + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/release" + helmtime "helm.sh/helm/v3/pkg/time" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta/testrestmapper" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +const manifestTemplate = `--- +# Source: templates/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +--- +# Source: templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: nami + namespace: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +data: + attack: "Gomu Gomu no King Kong Gun!" +--- +# Source: templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: zoro + namespace: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +spec: + type: ClusterIP + selector: + app: one-piece + ports: + - protocol: TCP + port: 80 + targetPort: 80 +--- +# Source: templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: luffy + namespace: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +spec: + replicas: 2 + selector: + matchLabels: + app: one-piece + template: + metadata: + labels: + app: one-piece + spec: + containers: + - name: luffy-arsenal + image: "nginx:1.21.6" + ports: + - containerPort: 80 + env: + - name: ATTACK + valueFrom: + configMapKeyRef: + name: luffy + key: attack +` + +func TestGetDeployed(t *testing.T) { + const ( + namespace = `thousand-sunny` + releaseName = `one-piece` + ) + var ( + is = assert.New(t) + manifest bytes.Buffer + relativeCreationTimestamp = time.Now().Add(-2 * time.Minute) + relativeCreationTimestampStr = relativeCreationTimestamp.Format(time.RFC3339) + exactCreationTimestamp = time.Date(2024, time.October, 28, 0, 4, 30, 0, time.FixedZone("IST", 19800)) + exactCreationTimestampStr = exactCreationTimestamp.Format(time.RFC3339) + scheme = runtime.NewScheme() + ) + + manifestTemplateParser, err := template.New("manifestTemplate").Parse(manifestTemplate) + is.NoError(err) + + is.NoError(corev1.AddToScheme(scheme)) + is.NoError(appsv1.AddToScheme(scheme)) + + restMapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(restMapper) + + prepareReleaseFunc := func(name, namespace, timestamp string, manifest bytes.Buffer, info *release.Info) []*release.Release { + err = manifestTemplateParser.Execute(&manifest, struct { + Namespace string + CreationTimestamp string + }{ + Namespace: namespace, + CreationTimestamp: timestamp, + }) + is.NoError(err) + + return []*release.Release{{ + Name: name, + Namespace: namespace, + Info: info, + Manifest: manifest.String(), + }} + } + + tests := []cmdTestCase{ + { + name: "get deployed with release", + cmd: fmt.Sprintf("get deployed %s --namespace %s", releaseName, namespace), + golden: "output/get-deployed.txt", + rels: prepareReleaseFunc( + releaseName, + namespace, + relativeCreationTimestampStr, + manifest, + &release.Info{ + LastDeployed: helmtime.Unix(relativeCreationTimestamp.Unix(), 0).UTC(), + Status: release.StatusDeployed, + }, + ), + restClientGetter: configFlags, + kubeClientOpts: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + }, + { + name: "get deployed with release in json format", + cmd: fmt.Sprintf("get deployed %s --namespace %s --output json", releaseName, namespace), + golden: "output/get-deployed.json", + rels: prepareReleaseFunc( + releaseName, + namespace, + exactCreationTimestampStr, + manifest, + &release.Info{ + LastDeployed: helmtime.Unix(exactCreationTimestamp.Unix(), 0).UTC(), + Status: release.StatusDeployed, + }, + ), + restClientGetter: configFlags, + kubeClientOpts: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + }, + { + name: "get deployed with release in yaml format", + cmd: fmt.Sprintf("get deployed %s --namespace %s --output yaml", releaseName, namespace), + golden: "output/get-deployed.yaml", + rels: prepareReleaseFunc( + releaseName, + namespace, + exactCreationTimestampStr, + manifest, + &release.Info{ + LastDeployed: helmtime.Unix(exactCreationTimestamp.Unix(), 0).UTC(), + Status: release.StatusDeployed, + }, + ), + restClientGetter: configFlags, + kubeClientOpts: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + }, + } + + runTestCmd(t, tests) +} diff --git a/cmd/helm/testdata/output/get-deployed.json b/cmd/helm/testdata/output/get-deployed.json new file mode 100644 index 00000000000..e65fde0ecfd --- /dev/null +++ b/cmd/helm/testdata/output/get-deployed.json @@ -0,0 +1 @@ +[{"name":"thousand-sunny","namespace":"","apiVersion":"v1","resource":"namespaces","creationTimestamp":"2024-10-27T18:34:30Z"},{"name":"nami","namespace":"thousand-sunny","apiVersion":"v1","resource":"configmaps","creationTimestamp":"2024-10-27T18:34:30Z"},{"name":"zoro","namespace":"thousand-sunny","apiVersion":"v1","resource":"services","creationTimestamp":"2024-10-27T18:34:30Z"},{"name":"luffy","namespace":"thousand-sunny","apiVersion":"apps/v1","resource":"deployments","creationTimestamp":"2024-10-27T18:34:30Z"}] diff --git a/cmd/helm/testdata/output/get-deployed.txt b/cmd/helm/testdata/output/get-deployed.txt new file mode 100644 index 00000000000..dd727aff2fd --- /dev/null +++ b/cmd/helm/testdata/output/get-deployed.txt @@ -0,0 +1,5 @@ +NAMESPACE NAME API_VERSION AGE + namespaces/thousand-sunny v1 2m +thousand-sunny configmaps/nami v1 2m +thousand-sunny services/zoro v1 2m +thousand-sunny deployments/luffy apps/v1 2m diff --git a/cmd/helm/testdata/output/get-deployed.yaml b/cmd/helm/testdata/output/get-deployed.yaml new file mode 100644 index 00000000000..6b82aab7764 --- /dev/null +++ b/cmd/helm/testdata/output/get-deployed.yaml @@ -0,0 +1,20 @@ +- apiVersion: v1 + creationTimestamp: "2024-10-27T18:34:30Z" + name: thousand-sunny + namespace: "" + resource: namespaces +- apiVersion: v1 + creationTimestamp: "2024-10-27T18:34:30Z" + name: nami + namespace: thousand-sunny + resource: configmaps +- apiVersion: v1 + creationTimestamp: "2024-10-27T18:34:30Z" + name: zoro + namespace: thousand-sunny + resource: services +- apiVersion: apps/v1 + creationTimestamp: "2024-10-27T18:34:30Z" + name: luffy + namespace: thousand-sunny + resource: deployments diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go index d6a937c718c..338f13491c0 100644 --- a/pkg/kube/fake/printer.go +++ b/pkg/kube/fake/printer.go @@ -17,14 +17,21 @@ limitations under the License. package fake import ( + "fmt" "io" "strings" + "sync" "time" - v1 "k8s.io/api/core/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer/json" "k8s.io/cli-runtime/pkg/resource" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/yaml" "helm.sh/helm/v3/pkg/kube" ) @@ -61,7 +68,19 @@ func (p *PrintingKubeClient) Get(resources kube.ResourceList, _ bool) (map[strin if err != nil { return nil, err } - return make(map[string][]runtime.Object), nil + + if p.Options == nil || !p.Options.GetReturnResourceMap { + return make(map[string][]runtime.Object), nil + } + + result := make(map[string][]runtime.Object) + for _, r := range resources { + result[r.Name] = []runtime.Object{ + r.Object, + } + } + + return result, nil } func (p *PrintingKubeClient) Wait(resources kube.ResourceList, _ time.Duration) error { @@ -109,8 +128,22 @@ func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, _ bool) (*kub } // Build implements KubeClient Build. -func (p *PrintingKubeClient) Build(_ io.Reader, _ bool) (kube.ResourceList, error) { - return []*resource.Info{}, nil +func (p *PrintingKubeClient) Build(in io.Reader, _ bool) (kube.ResourceList, error) { + if p.Options == nil || !p.Options.BuildReturnResourceList { + return []*resource.Info{}, nil + } + + manifest, err := (&kio.ByteReader{Reader: in}).Read() + if err != nil { + return nil, err + } + + resources, err := parseResources(manifest) + if err != nil { + return nil, err + } + + return resources, nil } // BuildTable implements KubeClient BuildTable. @@ -119,8 +152,8 @@ func (p *PrintingKubeClient) BuildTable(_ io.Reader, _ bool) (kube.ResourceList, } // WaitAndGetCompletedPodPhase implements KubeClient WaitAndGetCompletedPodPhase. -func (p *PrintingKubeClient) WaitAndGetCompletedPodPhase(_ string, _ time.Duration) (v1.PodPhase, error) { - return v1.PodSucceeded, nil +func (p *PrintingKubeClient) WaitAndGetCompletedPodPhase(_ string, _ time.Duration) (corev1.PodPhase, error) { + return corev1.PodSucceeded, nil } // DeleteWithPropagationPolicy implements KubeClient delete. @@ -141,3 +174,111 @@ func bufferize(resources kube.ResourceList) io.Reader { } return strings.NewReader(builder.String()) } + +// parseResources parses Kubernetes manifest YAML as resources suitable for Helm +func parseResources(manifest []*yaml.RNode) ([]*resource.Info, error) { + // Create a scheme + scheme := runtime.NewScheme() + + // Define serializer options + serializer := json.NewSerializerWithOptions( + json.DefaultMetaFactory, + scheme, + scheme, json.SerializerOptions{ + Yaml: true, + }, + ) + + var objects []*resource.Info + for _, node := range manifest { + // Get the GVK of the rNode + gvk, err := getGVKForNode(node) + if err != nil { + return nil, fmt.Errorf("failed to get the GVK of rNode: %v", err) + } + + // Add the GVK to scheme + err = addSchemeForGVK(scheme, gvk) + if err != nil { + return nil, fmt.Errorf("failed to add GVK %q to scheme: %v", gvk, err) + } + + // Convert the rNode to JSON bytes + jsonData, err := node.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("error marshaling RNode to JSON: %w", err) + } + + // Decode the JSON data into a Kubernetes runtime.Object + obj, _, err := serializer.Decode(jsonData, nil, nil) + if err != nil { + return nil, fmt.Errorf("error decoding JSON to runtime.Object: %w", err) + } + + objects = append(objects, &resource.Info{Object: obj}) + } + + return objects, nil +} + +// getGVKForNode returns GVK from an resource YAML node +func getGVKForNode(node *yaml.RNode) (schema.GroupVersionKind, error) { + // Retrieve the apiVersion field from the RNode + apiVersionNode, err := node.Pipe(yaml.Lookup(`apiVersion`)) + if err != nil || apiVersionNode == nil { + return schema.GroupVersionKind{}, fmt.Errorf("apiVersion not found in RNode: %v", err) + } + + // Retrieve the kind field from the RNode + kindNode, err := node.Pipe(yaml.Lookup(`kind`)) + if err != nil || kindNode == nil { + return schema.GroupVersionKind{}, fmt.Errorf("kind not found in RNode: %v", err) + } + + // Extract values + apiVersion := apiVersionNode.YNode().Value + kind := kindNode.YNode().Value + + // Parse the apiVersion to get GroupVersion + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return schema.GroupVersionKind{}, fmt.Errorf("error parsing apiVersion: %v", err) + } + + return gv.WithKind(kind), nil +} + +// Mutex to protect concurrent access to the scheme +var schemeMutex sync.Mutex + +// Registry to hold AddToScheme functions for each API group. +// Add more GroupVersion to AddToScheme func mappings if required by tests. +var addToSchemeRegistry = map[schema.GroupVersion]func(*runtime.Scheme) error{ + corev1.SchemeGroupVersion: corev1.AddToScheme, + appsv1.SchemeGroupVersion: appsv1.AddToScheme, +} + +// addSchemeForGVK dynamically adds GroupVersion to scheme +func addSchemeForGVK(scheme *runtime.Scheme, gvk schema.GroupVersionKind) error { + schemeMutex.Lock() + defer schemeMutex.Unlock() + + // Exit early if GroupVersion is already registered + gv := gvk.GroupVersion() + if scheme.IsVersionRegistered(gv) { + return nil + } + + // Look up the function corresponding to current GroupVersion + addToSchemeFunc, exists := addToSchemeRegistry[gv] + if !exists { + return fmt.Errorf("no AddToScheme function registered for %s", gv) + } + + // Register the GroupVersion in the scheme + if err := addToSchemeFunc(scheme); err != nil { + return fmt.Errorf("failed to add scheme for %s: %w", gv, err) + } + + return nil +} From b3149983149410a1d613b115b43221d9796f98ed Mon Sep 17 00:00:00 2001 From: Bhargav Ravuri Date: Sun, 3 Nov 2024 06:29:33 +0530 Subject: [PATCH 4/4] test(get deployed): Add pkg/action level tests Add pkg/action level tests related to `helm get deployed` command. Related to #12722 Signed-off-by: Bhargav Ravuri --- pkg/action/get_deployed.go | 14 +- pkg/action/get_deployed_test.go | 557 ++++++++++++++++++++++++++++++++ pkg/kube/fake/printer.go | 64 ++-- 3 files changed, 609 insertions(+), 26 deletions(-) create mode 100644 pkg/action/get_deployed_test.go diff --git a/pkg/action/get_deployed.go b/pkg/action/get_deployed.go index 4b60390c8b0..336e6941cea 100644 --- a/pkg/action/get_deployed.go +++ b/pkg/action/get_deployed.go @@ -115,7 +115,7 @@ func (g *GetDeployed) processResourceRecord(manifest *yaml.RNode, mapper meta.RE // Build resource list required for Helm kube client filter, err := g.cfg.KubeClient.Build(bytes.NewBufferString(manifestStr), false) if err != nil { - return nil, fmt.Errorf("failed to build resource list: %v", err) + return nil, fmt.Errorf("failed to build resource list: %w", err) } // Fetch the resources from the Kubernetes cluster based on the resource list built above @@ -124,12 +124,12 @@ func (g *GetDeployed) processResourceRecord(manifest *yaml.RNode, mapper meta.RE // the current record. list, err := g.cfg.KubeClient.Get(filter, false) if err != nil { - return nil, fmt.Errorf("failed to get the resource from cluster: %v", err) + return nil, fmt.Errorf("failed to get the resource from cluster: %w", err) } var ( resourceObj runtime.Object - metaObj metav1.Object + objMeta metav1.Object ) // Extract the resource object and its metadata from the list of resources. Note: Though Get() returns a list of @@ -139,12 +139,12 @@ func (g *GetDeployed) processResourceRecord(manifest *yaml.RNode, mapper meta.RE var ok bool for _, objects := range list { for _, obj := range objects { - metaObj, ok = obj.(metav1.Object) + objMeta, ok = obj.(metav1.Object) if !ok { return fmt.Errorf("object does not implement metav1.Object interface") } - if metaObj.GetName() != manifest.GetName() { + if objMeta.GetName() != manifest.GetName() { continue } @@ -170,9 +170,9 @@ func (g *GetDeployed) processResourceRecord(manifest *yaml.RNode, mapper meta.RE return &ResourceElement{ Resource: resourceMapping.Resource.Resource, Name: manifest.GetName(), - Namespace: metaObj.GetNamespace(), + Namespace: objMeta.GetNamespace(), APIVersion: manifest.GetApiVersion(), - CreationTimestamp: metaObj.GetCreationTimestamp(), + CreationTimestamp: objMeta.GetCreationTimestamp(), }, nil } diff --git a/pkg/action/get_deployed_test.go b/pkg/action/get_deployed_test.go new file mode 100644 index 00000000000..c5e99a362be --- /dev/null +++ b/pkg/action/get_deployed_test.go @@ -0,0 +1,557 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "bytes" + "fmt" + "io" + "testing" + texttemplate "text/template" + "time" + + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/release" + helmtime "helm.sh/helm/v3/pkg/time" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metatable "k8s.io/apimachinery/pkg/api/meta/table" + "k8s.io/apimachinery/pkg/api/meta/testrestmapper" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +const ( + manifestTemplate = `--- +# Source: templates/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +--- +# Source: templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: nami + namespace: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +data: + attack: "Gomu Gomu no King Kong Gun!" +--- +# Source: templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: zoro + namespace: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +spec: + type: ClusterIP + selector: + app: one-piece + ports: + - protocol: TCP + port: 80 + targetPort: 80 +--- +# Source: templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: luffy + namespace: {{ .Namespace }} + creationTimestamp: {{ .CreationTimestamp }} +spec: + replicas: 2 + selector: + matchLabels: + app: one-piece + template: + metadata: + labels: + app: one-piece + spec: + containers: + - name: luffy-arsenal + image: "nginx:1.21.6" + ports: + - containerPort: 80 + env: + - name: ATTACK + valueFrom: + configMapKeyRef: + name: luffy + key: attack +` + tableOutputTemplate = `NAMESPACE NAME API_VERSION AGE + namespaces/{{ .Namespace }} v1 {{ .Age }} +{{ .Namespace }} configmaps/nami v1 {{ .Age }} +{{ .Namespace }} services/zoro v1 {{ .Age }} +{{ .Namespace }} deployments/luffy apps/v1 {{ .Age }} +` + jsonOutputTemplate = `[{` + + `"name":"{{ .Namespace }}",` + + `"namespace":"",` + + `"apiVersion":"v1",` + + `"resource":"namespaces",` + + `"creationTimestamp":"{{ .CreationTimestamp }}"},{"name":"nami",` + + `"namespace":"{{ .Namespace }}",` + + `"apiVersion":"v1",` + + `"resource":"configmaps",` + + `"creationTimestamp":"{{ .CreationTimestamp }}"},{"name":"zoro",` + + `"namespace":"{{ .Namespace }}",` + + `"apiVersion":"v1",` + + `"resource":"services",` + + `"creationTimestamp":"{{ .CreationTimestamp }}"},{"name":"luffy",` + + `"namespace":"{{ .Namespace }}",` + + `"apiVersion":"apps/v1",` + + `"resource":"deployments",` + + `"creationTimestamp":"{{ .CreationTimestamp }}"` + + `}] +` + yamlOutputTemplate = `- apiVersion: v1 + creationTimestamp: "{{ .CreationTimestamp }}" + name: {{ .Namespace }} + namespace: "" + resource: namespaces +- apiVersion: v1 + creationTimestamp: "{{ .CreationTimestamp }}" + name: nami + namespace: {{ .Namespace }} + resource: configmaps +- apiVersion: v1 + creationTimestamp: "{{ .CreationTimestamp }}" + name: zoro + namespace: {{ .Namespace }} + resource: services +- apiVersion: apps/v1 + creationTimestamp: "{{ .CreationTimestamp }}" + name: luffy + namespace: {{ .Namespace }} + resource: deployments +` +) + +type getDeployedOutputData struct { + Namespace string + CreationTimestamp string + Age string +} + +func TestGetDeployed(t *testing.T) { + var ( + is = assert.New(t) + chartName = `one-piece` + namespace = `thousand-sunny` + exactTimestamp = time.Date(2024, time.October, 28, 0, 4, 30, 0, time.FixedZone("IST", 19800)).UTC() + relativeTimestamp = time.Now().Add(-2 * time.Minute).UTC() + ) + + type ( + testFunc struct { + writeTable bool + writeJSON bool + writeYAML bool + } + + testCase struct { + name string + creationTimestamp time.Time + testFunc testFunc + } + ) + + tests := []testCase{ + { + name: "With Exact Creation Time", + creationTimestamp: exactTimestamp, + testFunc: testFunc{ + writeTable: false, + writeJSON: true, + writeYAML: true, + }, + }, + { + name: "With Relative Creation Time", + creationTimestamp: relativeTimestamp, + testFunc: testFunc{ + writeTable: true, + writeJSON: false, + writeYAML: false, + }, + }, + } + + scheme := runtime.NewScheme() + is.NoError(corev1.AddToScheme(scheme)) + is.NoError(appsv1.AddToScheme(scheme)) + restMapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(restMapper) + + formatResourceList := func(creationTimestamp time.Time) []ResourceElement { + creationTimestampStr := creationTimestamp.Format(time.RFC3339) + + manifest, err := parseGetDeployedTestTemplate(namespace, creationTimestampStr, "", manifestTemplate) + is.NoError(err) + + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + } + config.RESTClientGetter = configFlags + + client := NewGetDeployed(config) + releases := []*release.Release{ + { + Name: chartName, + Info: &release.Info{ + LastDeployed: helmtime.Unix(creationTimestamp.Unix(), 0), + Status: release.StatusDeployed, + }, + Manifest: manifest.String(), + Namespace: namespace, + }, + } + + for _, rel := range releases { + err = client.cfg.Releases.Create(rel) + is.NoError(err) + } + + resourceList, err := client.Run(chartName) + is.NoError(err) + + return resourceList + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + is := assert.New(t) + resourceList := formatResourceList(tc.creationTimestamp) + is.NotEmpty(resourceList) + + writer := NewResourceListWriter(resourceList, false) + creationTimestampStr := tc.creationTimestamp.Format(time.RFC3339) + creationTimestampAgeStr := metatable.ConvertToHumanReadableDateType(metav1.NewTime(tc.creationTimestamp)) + + var ( + out bytes.Buffer + expectedOut fmt.Stringer + err error + ) + + if tc.testFunc.writeTable { + t.Run("Write Table", func(t *testing.T) { + is := assert.New(t) + out.Truncate(0) + + expectedOut, err = parseGetDeployedTestTemplate( + namespace, + "", // Creation timestamp is not used in table output, but creation timestamp's age + creationTimestampAgeStr, + tableOutputTemplate, + ) + is.NoError(err) + + err = writer.WriteTable(&out) + is.NoError(err) + is.Equal(expectedOut.String(), out.String()) + }) + } + + if tc.testFunc.writeJSON { + t.Run("Write JSON", func(t *testing.T) { + is := assert.New(t) + out.Truncate(0) + + expectedOut, err = parseGetDeployedTestTemplate( + namespace, + creationTimestampStr, + "", // Creation timestamp's age is not used in JSON output, but the creation timestamp itself + jsonOutputTemplate, + ) + is.NoError(err) + + err = writer.WriteJSON(&out) + is.NoError(err) + is.Equal(expectedOut.String(), out.String()) + }) + } + + if tc.testFunc.writeYAML { + t.Run("Write YAML", func(t *testing.T) { + is := assert.New(t) + out.Truncate(0) + + expectedOut, err = parseGetDeployedTestTemplate( + namespace, + creationTimestampStr, + "", // Creation timestamp's age is not used in YAML output, but the creation timestamp itself + yamlOutputTemplate, + ) + is.NoError(err) + + err = writer.WriteYAML(&out) + is.NoError(err) + is.Equal(expectedOut.String(), out.String()) + }) + } + }) + + } +} + +func parseGetDeployedTestTemplate(namespace, creationTimestamp, age, template string) (fmt.Stringer, error) { + outputParser, err := texttemplate.New("template").Parse(template) + if err != nil { + return nil, err + } + + var out bytes.Buffer + err = outputParser.Execute(&out, getDeployedOutputData{ + Namespace: namespace, + CreationTimestamp: creationTimestamp, + Age: age, + }) + if err != nil { + return nil, err + } + + return &out, nil +} + +func TestGetDeployed_ErrorKubeClientNotReachable(t *testing.T) { + is := assert.New(t) + chartName := `one-piece` + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + IsReachableReturnsError: true, + }, + } + + client := NewGetDeployed(config) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.ErrorIs(err, kubefake.ErrPrintingKubeClientNotReachable) +} + +func TestGetDeployed_ErrorReleaseNotFound(t *testing.T) { + is := assert.New(t) + chartName := `one-piece` + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + IsReachableReturnsError: false, + }, + } + + client := NewGetDeployed(config) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.Contains(err.Error(), "release: not found") +} + +func TestGetDeployed_RESTMapperNotFound(t *testing.T) { + var ( + is = assert.New(t) + chartName = `one-piece` + ) + + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(nil) + + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + } + config.RESTClientGetter = configFlags + + client := NewGetDeployed(config) + + err := client.cfg.Releases.Create(&release.Release{ + Name: chartName, + Info: &release.Info{}, + }) + is.NoError(err) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.Contains(err.Error(), "failed to extract the REST mapper: no restmapper") +} + +func TestGetDeployed_ResourceListBuildFailure(t *testing.T) { + var ( + is = assert.New(t) + chartName = `one-piece` + namespace = `thousand-sunny` + exactTimestamp = time.Date(2024, time.October, 28, 0, 4, 30, 0, time.FixedZone("IST", 19800)).UTC() + ) + + scheme := runtime.NewScheme() + is.NoError(corev1.AddToScheme(scheme)) + is.NoError(appsv1.AddToScheme(scheme)) + restMapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(restMapper) + + creationTimestampStr := exactTimestamp.Format(time.RFC3339) + manifest, err := parseGetDeployedTestTemplate(namespace, creationTimestampStr, "", manifestTemplate) + is.NoError(err) + + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + BuildReturnError: true, + }, + } + config.RESTClientGetter = configFlags + + client := NewGetDeployed(config) + + err = client.cfg.Releases.Create(&release.Release{ + Name: chartName, + Info: &release.Info{ + LastDeployed: helmtime.Unix(exactTimestamp.Unix(), 0), + Status: release.StatusDeployed, + }, + Manifest: manifest.String(), + Namespace: namespace, + }) + is.NoError(err) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.ErrorIs(err, kubefake.ErrPrintingKubeClientBuildFailure) +} + +func TestGetDeployed_GetResourceFailure(t *testing.T) { + var ( + is = assert.New(t) + chartName = `one-piece` + namespace = `thousand-sunny` + exactTimestamp = time.Date(2024, time.October, 28, 0, 4, 30, 0, time.FixedZone("IST", 19800)).UTC() + ) + + scheme := runtime.NewScheme() + is.NoError(corev1.AddToScheme(scheme)) + is.NoError(appsv1.AddToScheme(scheme)) + restMapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(restMapper) + + creationTimestampStr := exactTimestamp.Format(time.RFC3339) + manifest, err := parseGetDeployedTestTemplate(namespace, creationTimestampStr, "", manifestTemplate) + is.NoError(err) + + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + GetReturnError: true, + }, + } + config.RESTClientGetter = configFlags + + client := NewGetDeployed(config) + + err = client.cfg.Releases.Create(&release.Release{ + Name: chartName, + Info: &release.Info{ + LastDeployed: helmtime.Unix(exactTimestamp.Unix(), 0), + Status: release.StatusDeployed, + }, + Manifest: manifest.String(), + Namespace: namespace, + }) + is.NoError(err) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.ErrorIs(err, kubefake.ErrPrintingKubeClientGetFailure) +} + +func TestGetDeployed_MissingGVK(t *testing.T) { + var ( + is = assert.New(t) + chartName = `one-piece` + namespace = `thousand-sunny` + exactTimestamp = time.Date(2024, time.October, 28, 0, 4, 30, 0, time.FixedZone("IST", 19800)).UTC() + ) + + scheme := runtime.NewScheme() + is.NoError(corev1.AddToScheme(scheme)) + restMapper := testrestmapper.TestOnlyStaticRESTMapper(scheme) + configFlags := genericclioptions.NewTestConfigFlags(). + WithRESTMapper(restMapper) + + creationTimestampStr := exactTimestamp.Format(time.RFC3339) + + manifest, err := parseGetDeployedTestTemplate(namespace, creationTimestampStr, "", manifestTemplate) + is.NoError(err) + + config := actionConfigFixture(t) + config.KubeClient = &kubefake.PrintingKubeClient{ + Out: io.Discard, + Options: &kubefake.Options{ + GetReturnResourceMap: true, + BuildReturnResourceList: true, + }, + } + config.RESTClientGetter = configFlags + + client := NewGetDeployed(config) + err = client.cfg.Releases.Create(&release.Release{ + Name: chartName, + Info: &release.Info{ + LastDeployed: helmtime.Unix(exactTimestamp.Unix(), 0), + Status: release.StatusDeployed, + }, + Manifest: manifest.String(), + Namespace: namespace, + }) + is.NoError(err) + + resourceList, err := client.Run(chartName) + is.Nil(resourceList) + is.Error(err) + is.Contains(err.Error(), "no matches for kind \"Deployment\" in version \"apps/v1\"") +} diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go index 338f13491c0..fcf07e5380e 100644 --- a/pkg/kube/fake/printer.go +++ b/pkg/kube/fake/printer.go @@ -17,6 +17,7 @@ limitations under the License. package fake import ( + "errors" "fmt" "io" "strings" @@ -39,7 +40,10 @@ import ( // Options to control the fake behavior of PrintingKubeClient type Options struct { GetReturnResourceMap bool + GetReturnError bool BuildReturnResourceList bool + BuildReturnError bool + IsReachableReturnsError bool } // PrintingKubeClient implements KubeClient, but simply prints the reader to @@ -49,8 +53,18 @@ type PrintingKubeClient struct { Options *Options } +var ( + ErrPrintingKubeClientNotReachable = errors.New("kubernetes cluster not reachable") + ErrPrintingKubeClientBuildFailure = errors.New("failed to build resource list") + ErrPrintingKubeClientGetFailure = errors.New("failed to get resource") +) + // IsReachable checks if the cluster is reachable func (p *PrintingKubeClient) IsReachable() error { + if p.Options != nil && p.Options.IsReachableReturnsError { + return ErrPrintingKubeClientNotReachable + } + return nil } @@ -69,18 +83,24 @@ func (p *PrintingKubeClient) Get(resources kube.ResourceList, _ bool) (map[strin return nil, err } - if p.Options == nil || !p.Options.GetReturnResourceMap { - return make(map[string][]runtime.Object), nil - } + if p.Options != nil { + if p.Options.GetReturnError { + return nil, ErrPrintingKubeClientGetFailure + } + + if p.Options.GetReturnResourceMap { + result := make(map[string][]runtime.Object) + for _, r := range resources { + result[r.Name] = []runtime.Object{ + r.Object, + } + } - result := make(map[string][]runtime.Object) - for _, r := range resources { - result[r.Name] = []runtime.Object{ - r.Object, + return result, nil } } - return result, nil + return make(map[string][]runtime.Object), nil } func (p *PrintingKubeClient) Wait(resources kube.ResourceList, _ time.Duration) error { @@ -129,21 +149,27 @@ func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, _ bool) (*kub // Build implements KubeClient Build. func (p *PrintingKubeClient) Build(in io.Reader, _ bool) (kube.ResourceList, error) { - if p.Options == nil || !p.Options.BuildReturnResourceList { - return []*resource.Info{}, nil - } + if p.Options != nil { + if p.Options.BuildReturnError { + return nil, ErrPrintingKubeClientBuildFailure + } - manifest, err := (&kio.ByteReader{Reader: in}).Read() - if err != nil { - return nil, err - } + if p.Options.BuildReturnResourceList { + manifest, err := (&kio.ByteReader{Reader: in}).Read() + if err != nil { + return nil, err + } - resources, err := parseResources(manifest) - if err != nil { - return nil, err + resources, err := parseResources(manifest) + if err != nil { + return nil, err + } + + return resources, nil + } } - return resources, nil + return []*resource.Info{}, nil } // BuildTable implements KubeClient BuildTable.